Compare commits
4 Commits
Actualizac
...
34754810ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34754810ba | ||
|
|
c3668d047f | ||
|
|
6315307e4a | ||
|
|
1f940e8164 |
14
.clippy.toml
14
.clippy.toml
@@ -1,14 +0,0 @@
|
|||||||
# Clippy configuration for OmniOil workspace
|
|
||||||
# https://doc.rust-lang.org/clippy/configuration.html
|
|
||||||
|
|
||||||
# Disallow unwrap() in production code (allowed in tests)
|
|
||||||
disallowed-methods = [
|
|
||||||
{ path = "std::option::Option::unwrap", reason = "Use ? operator or pattern matching instead" },
|
|
||||||
{ path = "std::result::Result::unwrap", reason = "Use ? operator or pattern matching instead" },
|
|
||||||
]
|
|
||||||
|
|
||||||
# Warn on complexity
|
|
||||||
cognitive-complexity-threshold = 25
|
|
||||||
|
|
||||||
# Allow certain patterns that are intentional
|
|
||||||
allow-unwrap-in-tests = true
|
|
||||||
@@ -58,7 +58,6 @@ MOSQUITTO_GO_AUTH=true # Habilita autenticaci
|
|||||||
|
|
||||||
# --- SEGURIDAD Y TÚNELES ---
|
# --- SEGURIDAD Y TÚNELES ---
|
||||||
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
||||||
ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app # Revisores de solicitudes de acceso
|
|
||||||
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
|
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
|
||||||
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
||||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS
|
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS
|
||||||
@@ -67,10 +66,6 @@ VITE_LANDING_APP_ORIGIN=http://localhost:3004/
|
|||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||||
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
||||||
|
|
||||||
# --- SEGURIDAD DE DESCARGA DE AGENTES (OTA) ---
|
|
||||||
AGENT_DOWNLOAD_SECRET=clave_secreta_para_firmar_descargas # HMAC secret para firmar URLs de descarga de agentes
|
|
||||||
# Si no se configura, las descargas operan sin autenticación
|
|
||||||
|
|
||||||
# --- ORQUESTACIÓN Y REGISTRY ---
|
# --- ORQUESTACIÓN Y REGISTRY ---
|
||||||
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
|
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
|
||||||
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC
|
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC
|
||||||
|
|||||||
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
@@ -1,63 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: -D warnings
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rust-check:
|
|
||||||
name: Rust Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
components: rustfmt, clippy
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt -- --check
|
|
||||||
- name: Run clippy
|
|
||||||
run: cargo clippy --workspace -- -D warnings
|
|
||||||
- name: Run cargo check
|
|
||||||
run: cargo check --workspace
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --workspace
|
|
||||||
|
|
||||||
frontend-lint:
|
|
||||||
name: Frontend Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
app: [frontend-admin, frontend-pwa, landing]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: services/${{ matrix.app }}/package-lock.json
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
working-directory: services/${{ matrix.app }}
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint
|
|
||||||
working-directory: services/${{ matrix.app }}
|
|
||||||
- name: TypeScript check
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
working-directory: services/${{ matrix.app }}
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
docker-build:
|
|
||||||
name: Docker Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [rust-check]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Build Docker images
|
|
||||||
run: docker compose build
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v5.0.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-added-large-files
|
|
||||||
- id: check-merge-conflict
|
|
||||||
- id: detect-private-key
|
|
||||||
|
|
||||||
- repo: https://github.com/doublify/pre-commit-rust
|
|
||||||
rev: v1.0
|
|
||||||
hooks:
|
|
||||||
- id: fmt
|
|
||||||
- id: cargo-check
|
|
||||||
79
Cargo.lock
generated
79
Cargo.lock
generated
@@ -971,8 +971,6 @@ dependencies = [
|
|||||||
"csv",
|
"csv",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"governor",
|
"governor",
|
||||||
"hex",
|
|
||||||
"hmac 0.12.1",
|
|
||||||
"json-generator",
|
"json-generator",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -1170,11 +1168,9 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"hex",
|
"hex",
|
||||||
"prometheus",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rumqttc",
|
"rumqttc",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1183,7 +1179,6 @@ dependencies = [
|
|||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1925,7 +1920,7 @@ dependencies = [
|
|||||||
"tray-icon",
|
"tray-icon",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
"winit",
|
"winit",
|
||||||
"winres",
|
"winres",
|
||||||
"zstd",
|
"zstd",
|
||||||
@@ -2050,17 +2045,14 @@ name = "events-relay"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"prometheus",
|
|
||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3249,7 +3241,7 @@ dependencies = [
|
|||||||
"image-webp",
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png 0.18.1",
|
||||||
"qoi",
|
"qoi",
|
||||||
"ravif",
|
"ravif",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3413,19 +3405,16 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"hex",
|
"hex",
|
||||||
"prometheus",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3827,9 +3816,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.3"
|
version = "0.17.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
|
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -3841,9 +3830,9 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4658,6 +4647,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.17.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -4849,27 +4851,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prometheus"
|
|
||||||
version = "0.13.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"fnv",
|
|
||||||
"lazy_static",
|
|
||||||
"memchr",
|
|
||||||
"parking_lot",
|
|
||||||
"protobuf",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protobuf"
|
|
||||||
version = "2.28.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptr_meta"
|
name = "ptr_meta"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -6457,17 +6438,14 @@ name = "telemetry-ingestor"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"prometheus",
|
|
||||||
"rumqttc",
|
"rumqttc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -7016,16 +6994,6 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-serde"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"tracing-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
@@ -7036,22 +7004,19 @@ dependencies = [
|
|||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
"tracing-serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tray-icon"
|
name = "tray-icon"
|
||||||
version = "0.24.1"
|
version = "0.22.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
checksum = "76b42a907631429634f9f57ef72f9e7aec3e12e74050636138acb3752ecb8df3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -7063,7 +7028,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.18.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ chrono-tz = "0.10.4"
|
|||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
||||||
rumqttc = { version = "0.25.1", features = ["use-rustls"] }
|
rumqttc = { version = "0.25.1", features = ["use-rustls"] }
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
@@ -42,7 +42,6 @@ tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"
|
|||||||
s7 = "0.1.2"
|
s7 = "0.1.2"
|
||||||
semver = "1.0"
|
semver = "1.0"
|
||||||
shared-lib = { path = "services/shared-lib" }
|
shared-lib = { path = "services/shared-lib" }
|
||||||
prometheus = "0.13"
|
|
||||||
|
|
||||||
# ── Disk-space optimized profiles ──────────────────────────────────────────────
|
# ── Disk-space optimized profiles ──────────────────────────────────────────────
|
||||||
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
|
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
|
||||||
|
|||||||
@@ -20,19 +20,22 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|||||||
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||||
|
|
||||||
# --- Stage 2: Base builder ---
|
# --- Stage 2: Base builder ---
|
||||||
FROM deps AS builder_base
|
FROM deps AS builder
|
||||||
|
# Copiar únicamente las carpetas de origen del frontend para no invalidar por cambios en backend/Rust
|
||||||
|
COPY services/landing services/landing
|
||||||
|
COPY services/frontend-admin services/frontend-admin
|
||||||
|
COPY services/frontend-console services/frontend-console
|
||||||
|
COPY services/frontend-pwa services/frontend-pwa
|
||||||
|
|
||||||
# --- Stage 3: Landing build ---
|
# --- Stage 3: Landing build ---
|
||||||
FROM builder_base AS builder-landing
|
FROM builder AS builder-landing
|
||||||
COPY services/landing services/landing
|
|
||||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
RUN pnpm --filter landing build
|
RUN pnpm --filter landing build
|
||||||
|
|
||||||
# --- Stage 4: Admin build ---
|
# --- Stage 4: Admin build ---
|
||||||
FROM builder_base AS builder-admin
|
FROM builder AS builder-admin
|
||||||
COPY services/frontend-admin services/frontend-admin
|
|
||||||
ARG VITE_API_BASE
|
ARG VITE_API_BASE
|
||||||
ARG VITE_ADMIN_APP_ORIGIN
|
ARG VITE_ADMIN_APP_ORIGIN
|
||||||
ARG VITE_MOBILE_APP_ORIGIN
|
ARG VITE_MOBILE_APP_ORIGIN
|
||||||
@@ -48,8 +51,7 @@ ENV VITE_LANDING_APP_ORIGIN=$VITE_LANDING_APP_ORIGIN
|
|||||||
RUN pnpm --filter frontend-admin build
|
RUN pnpm --filter frontend-admin build
|
||||||
|
|
||||||
# --- Stage 5: Console build ---
|
# --- Stage 5: Console build ---
|
||||||
FROM builder_base AS builder-console
|
FROM builder AS builder-console
|
||||||
COPY services/frontend-console services/frontend-console
|
|
||||||
ARG VITE_API_BASE
|
ARG VITE_API_BASE
|
||||||
ARG VITE_CONSOLE_APP_ORIGIN
|
ARG VITE_CONSOLE_APP_ORIGIN
|
||||||
ARG VITE_ACCESS_REQUEST_REVIEWER_EMAILS
|
ARG VITE_ACCESS_REQUEST_REVIEWER_EMAILS
|
||||||
@@ -59,8 +61,7 @@ ENV VITE_ACCESS_REQUEST_REVIEWER_EMAILS=$VITE_ACCESS_REQUEST_REVIEWER_EMAILS
|
|||||||
RUN pnpm --filter frontend-console build
|
RUN pnpm --filter frontend-console build
|
||||||
|
|
||||||
# --- Stage 6: PWA build ---
|
# --- Stage 6: PWA build ---
|
||||||
FROM builder_base AS builder-pwa
|
FROM builder AS builder-pwa
|
||||||
COPY services/frontend-pwa services/frontend-pwa
|
|
||||||
ARG VITE_API_BASE
|
ARG VITE_API_BASE
|
||||||
ARG VITE_ADMIN_APP_ORIGIN
|
ARG VITE_ADMIN_APP_ORIGIN
|
||||||
ARG VITE_MOBILE_APP_ORIGIN
|
ARG VITE_MOBILE_APP_ORIGIN
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ RUN cargo chef prepare --recipe-path recipe.json
|
|||||||
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
||||||
FROM base AS cacher
|
FROM base AS cacher
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
|
||||||
--mount=type=cache,target=/app/target \
|
|
||||||
cargo chef cook --release --recipe-path recipe.json
|
|
||||||
|
|
||||||
# 3a. Cook default workspace dependencies (Linux GNU)
|
# 3a. Cook default workspace dependencies (Linux GNU)
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
|||||||
76
Makefile
76
Makefile
@@ -1,76 +0,0 @@
|
|||||||
.PHONY: all build test lint format check dev up down clean help
|
|
||||||
|
|
||||||
all: help
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Usage: make <target>"
|
|
||||||
@echo ""
|
|
||||||
@echo "Targets:"
|
|
||||||
@echo " build Build all Rust services (release)"
|
|
||||||
@echo " build-dev Build all Rust services (debug)"
|
|
||||||
@echo " test Run all cargo tests"
|
|
||||||
@echo " test-pkg Run tests for a specific package: make test-pkg PKG=shared-lib"
|
|
||||||
@echo " lint Run all linters (cargo fmt --check + npm run lint)"
|
|
||||||
@echo " fmt Format all Rust code (cargo fmt)"
|
|
||||||
@echo " check Run cargo check on all packages"
|
|
||||||
@echo " dev Start full dev stack (docker compose up)"
|
|
||||||
@echo " up Start production stack"
|
|
||||||
@echo " down Stop all containers"
|
|
||||||
@echo " clean Remove build artifacts"
|
|
||||||
@echo " logs Tail logs from a service: make logs SVC=backend-api"
|
|
||||||
@echo " db-migrate Run database migrations"
|
|
||||||
@echo " npm-install Install npm dependencies for all frontends"
|
|
||||||
@echo " ci Full CI pipeline (check + test + lint)"
|
|
||||||
|
|
||||||
build:
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
build-dev:
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
test:
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
test-pkg:
|
|
||||||
cargo test -p $(PKG)
|
|
||||||
|
|
||||||
lint:
|
|
||||||
cargo fmt -- --check
|
|
||||||
cd services/frontend-admin && npm run lint 2>/dev/null || true
|
|
||||||
cd services/frontend-pwa && npm run lint 2>/dev/null || true
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
check:
|
|
||||||
cargo check --workspace
|
|
||||||
|
|
||||||
dev:
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
|
||||||
|
|
||||||
dev-build:
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
|
||||||
|
|
||||||
up:
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
down:
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
clean:
|
|
||||||
cargo clean
|
|
||||||
rm -rf services/frontend-*/node_modules 2>/dev/null || true
|
|
||||||
|
|
||||||
logs:
|
|
||||||
docker compose logs -f $(SVC)
|
|
||||||
|
|
||||||
db-migrate:
|
|
||||||
cargo run -p backend-api --bin migrate
|
|
||||||
|
|
||||||
npm-install:
|
|
||||||
cd services/frontend-admin && npm install
|
|
||||||
cd services/frontend-pwa && npm install
|
|
||||||
cd services/frontend-console && npm install
|
|
||||||
cd services/landing && npm install
|
|
||||||
|
|
||||||
ci: check test lint
|
|
||||||
@@ -113,8 +113,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
expose:
|
|
||||||
- "9092"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -136,8 +134,6 @@ services:
|
|||||||
- MQTT_PORT=1883
|
- MQTT_PORT=1883
|
||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
expose:
|
|
||||||
- "9091"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
timescaledb:
|
timescaledb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -161,8 +157,6 @@ services:
|
|||||||
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
- AWS_REGION=${AWS_REGION:-us-east-1}
|
- AWS_REGION=${AWS_REGION:-us-east-1}
|
||||||
expose:
|
|
||||||
- "9093"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -287,8 +281,6 @@ services:
|
|||||||
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
||||||
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
||||||
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
||||||
expose:
|
|
||||||
- "9094"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
||||||
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
||||||
@@ -325,7 +317,6 @@ services:
|
|||||||
- '--web.enable-lifecycle'
|
- '--web.enable-lifecycle'
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
- ./infrastructure/monitoring/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
|
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
@@ -354,44 +345,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- prometheus
|
- prometheus
|
||||||
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:3.6.0
|
|
||||||
container_name: anh_loki
|
|
||||||
restart: always
|
|
||||||
command: -config.file=/etc/loki/loki-config.yml
|
|
||||||
volumes:
|
|
||||||
- ./infrastructure/monitoring/loki-config.yml:/etc/loki/loki-config.yml:ro
|
|
||||||
- loki_data:/loki
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:3.6.0
|
|
||||||
container_name: anh_promtail
|
|
||||||
restart: always
|
|
||||||
command: -config.file=/etc/promtail/promtail-config.yml
|
|
||||||
volumes:
|
|
||||||
- ./infrastructure/monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
|
|
||||||
alertmanager:
|
|
||||||
image: prom/alertmanager:v0.28.1
|
|
||||||
container_name: anh_alertmanager
|
|
||||||
restart: always
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
|
||||||
- '--storage.path=/alertmanager'
|
|
||||||
volumes:
|
|
||||||
- ./infrastructure/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
|
||||||
- alertmanager_data:/alertmanager
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
|
|
||||||
node-exporter:
|
node-exporter:
|
||||||
image: prom/node-exporter:v1.9.1
|
image: prom/node-exporter:v1.9.1
|
||||||
container_name: anh_node_exporter
|
container_name: anh_node_exporter
|
||||||
@@ -423,5 +376,3 @@ volumes:
|
|||||||
installer_output:
|
installer_output:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
loki_data:
|
|
||||||
alertmanager_data:
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# Telemetry Origin Contract
|
|
||||||
|
|
||||||
OmniOil distinguishes telemetry by origin, not by UI labels or polling heuristics. The backend persists the origin, downstream services derive frequency from it, and the dashboard consumes the same contract.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
| Concept | Contract |
|
|
||||||
| --- | --- |
|
|
||||||
| High frequency | `source = SCADA` → `frequency_class = HF` |
|
|
||||||
| Low frequency | `source = PWA` or `source = MANUAL` → `frequency_class = LF` |
|
|
||||||
| Movements | Operational events, not telemetry signal |
|
|
||||||
| Unknown origin | Do not count as HF or LF |
|
|
||||||
|
|
||||||
`frequency_class` is derived from `source`; it is not an independent persisted database column.
|
|
||||||
|
|
||||||
## Write Paths
|
|
||||||
|
|
||||||
| Path | Source | Persistence rule |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Edge MQTT telemetry | `SCADA` | Resolve `(project_id, device_name)` to exactly one asset before insert. Drop unresolved or ambiguous samples with warning/metric. |
|
|
||||||
| PWA `/api/telemetry` | `PWA` | Derive `project_id` from `asset_id`, validate user project access, persist `client_id`. Reject `SCADA` over HTTP. |
|
|
||||||
| Manual HTTP telemetry | `MANUAL` | Same LF freshness semantics as PWA. |
|
|
||||||
| PWA `/api/movements` | not telemetry | Deduplicate retries by movement `client_id`; movements do not feed silence checks or HF/LF counts. |
|
|
||||||
|
|
||||||
## Freshness And Silence
|
|
||||||
|
|
||||||
| Alarm type | Origin | Default window |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `TELEMETRY_SILENCE_HF` | `SCADA` | 10 minutes |
|
|
||||||
| `TELEMETRY_SILENCE_LF` | `PWA`, `MANUAL` | 24 hours |
|
|
||||||
|
|
||||||
Legacy `TELEMETRY_SILENCE` is treated as HF/deprecated for compatibility.
|
|
||||||
|
|
||||||
## Dashboard Rules
|
|
||||||
|
|
||||||
- Panorama HF/LF cards count normalized telemetry aliases by authoritative origin.
|
|
||||||
- The dashboard does not infer HF/LF from polling interval.
|
|
||||||
- LF samples must not make the global SCADA/HF status look healthy.
|
|
||||||
- Unknown origin remains visible as telemetry but does not enter HF/LF buckets.
|
|
||||||
|
|
||||||
## Post-Deploy Checklist
|
|
||||||
|
|
||||||
Run these checks after applying migrations in an environment with TimescaleDB.
|
|
||||||
|
|
||||||
### 1. Confirm source backfill and chunks
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
(SELECT count(*) FROM telemetry_raw) AS rows,
|
|
||||||
count(*) FILTER (WHERE is_compressed) AS compressed_chunks,
|
|
||||||
count(*) AS total_chunks
|
|
||||||
FROM timescaledb_information.chunks
|
|
||||||
WHERE hypertable_name = 'telemetry_raw';
|
|
||||||
|
|
||||||
SELECT source, count(*)
|
|
||||||
FROM telemetry_raw
|
|
||||||
GROUP BY source
|
|
||||||
ORDER BY source;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: every `telemetry_raw.source` is non-null and existing rows are backfilled to `SCADA` unless written later as `PWA` or `MANUAL`.
|
|
||||||
|
|
||||||
### 2. Confirm telemetry idempotency
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT time, asset_id, count(*)
|
|
||||||
FROM telemetry_raw
|
|
||||||
GROUP BY time, asset_id
|
|
||||||
HAVING count(*) > 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: zero rows.
|
|
||||||
|
|
||||||
### 3. Confirm movement idempotency
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT asset_id, details->>'client_id' AS client_id, count(*)
|
|
||||||
FROM operation_movements
|
|
||||||
WHERE details->>'client_id' IS NOT NULL
|
|
||||||
AND details->>'client_id' <> ''
|
|
||||||
GROUP BY asset_id, details->>'client_id'
|
|
||||||
HAVING count(*) > 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: zero rows.
|
|
||||||
|
|
||||||
### 4. Watch MQTT resolution metrics
|
|
||||||
|
|
||||||
- `omnioil_telemetry_unresolved_device_total`
|
|
||||||
- `omnioil_telemetry_ambiguous_device_total`
|
|
||||||
|
|
||||||
If `ambiguous` increases, audit duplicate device names within a project before adding any structural uniqueness constraint.
|
|
||||||
|
|
||||||
## Follow-Ups
|
|
||||||
|
|
||||||
- Fix baseline TypeScript and Rust formatting gates separately if CI requires full workspace checks.
|
|
||||||
- Add structural uniqueness for `(project_id, device_name)` only after auditing current device data.
|
|
||||||
- The LF simulator is intentionally out of scope for this contract document.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
global:
|
|
||||||
resolve_timeout: 5m
|
|
||||||
|
|
||||||
route:
|
|
||||||
receiver: 'default'
|
|
||||||
group_wait: 30s
|
|
||||||
group_interval: 5m
|
|
||||||
repeat_interval: 4h
|
|
||||||
|
|
||||||
receivers:
|
|
||||||
- name: 'default'
|
|
||||||
slack_configs:
|
|
||||||
- send_resolved: true
|
|
||||||
title: '{{ .GroupLabels.alertname }}'
|
|
||||||
text: '{{ .CommonAnnotations.description }}'
|
|
||||||
@@ -6,10 +6,3 @@ datasources:
|
|||||||
url: http://prometheus:9090
|
url: http://prometheus:9090
|
||||||
isDefault: true
|
isDefault: true
|
||||||
editable: false
|
editable: false
|
||||||
|
|
||||||
- name: Loki
|
|
||||||
type: loki
|
|
||||||
access: proxy
|
|
||||||
url: http://loki:3100
|
|
||||||
isDefault: false
|
|
||||||
editable: false
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
auth_enabled: false
|
|
||||||
|
|
||||||
server:
|
|
||||||
http_listen_port: 3100
|
|
||||||
grpc_listen_port: 9096
|
|
||||||
|
|
||||||
common:
|
|
||||||
instance_addr: 0.0.0.0
|
|
||||||
path_prefix: /loki
|
|
||||||
storage:
|
|
||||||
filesystem:
|
|
||||||
chunks_directory: /loki/chunks
|
|
||||||
rules_directory: /loki/rules
|
|
||||||
replication_factor: 1
|
|
||||||
ring:
|
|
||||||
kvstore:
|
|
||||||
store: inmemory
|
|
||||||
|
|
||||||
schema_config:
|
|
||||||
configs:
|
|
||||||
- from: 2020-10-24
|
|
||||||
store: tsdb
|
|
||||||
object_store: filesystem
|
|
||||||
schema: v13
|
|
||||||
index:
|
|
||||||
prefix: index_
|
|
||||||
period: 24h
|
|
||||||
|
|
||||||
compactor:
|
|
||||||
working_directory: /loki/compactor
|
|
||||||
retention_enabled: true
|
|
||||||
|
|
||||||
limits_config:
|
|
||||||
reject_old_samples: true
|
|
||||||
reject_old_samples_max_age: 168h
|
|
||||||
|
|
||||||
ruler:
|
|
||||||
alertmanager_url: http://alertmanager:9093
|
|
||||||
enable_alertmanager_v2: true
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
groups:
|
|
||||||
- name: omnioil_alerts
|
|
||||||
interval: 30s
|
|
||||||
rules:
|
|
||||||
- alert: BackendAPIDown
|
|
||||||
expr: up{job="omnioil-api"} == 0
|
|
||||||
for: 1m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Backend API is down"
|
|
||||||
description: "Backend API has been unreachable for more than 1 minute."
|
|
||||||
|
|
||||||
- alert: HighMQTTConnectionErrors
|
|
||||||
expr: rate(omnioil_mqtt_connections_total[5m]) > 0.1
|
|
||||||
for: 2m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "High MQTT reconnection rate"
|
|
||||||
description: "MQTT is reconnecting more than once per 10 seconds."
|
|
||||||
|
|
||||||
- alert: ActiveAgentsLow
|
|
||||||
expr: omnioil_active_agents < 1
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "No active agents"
|
|
||||||
description: "There are no active edge agents reporting health."
|
|
||||||
@@ -2,14 +2,6 @@ global:
|
|||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
evaluation_interval: 15s
|
evaluation_interval: 15s
|
||||||
|
|
||||||
alerting:
|
|
||||||
alertmanagers:
|
|
||||||
- static_configs:
|
|
||||||
- targets: ['alertmanager:9093']
|
|
||||||
|
|
||||||
rule_files:
|
|
||||||
- /etc/prometheus/prometheus-rules.yml
|
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
- job_name: 'omnioil-api'
|
- job_name: 'omnioil-api'
|
||||||
static_configs:
|
static_configs:
|
||||||
@@ -17,26 +9,6 @@ scrape_configs:
|
|||||||
metrics_path: '/metrics'
|
metrics_path: '/metrics'
|
||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
|
|
||||||
- job_name: 'telemetry-ingestor'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['telemetry-ingestor:9091']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'events-relay'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['events-relay:9092']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'json-generator'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['json-generator:9093']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'build-orchestrator'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['build-orchestrator:9094']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'node-exporter'
|
- job_name: 'node-exporter'
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['node-exporter:9100']
|
- targets: ['node-exporter:9100']
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
server:
|
|
||||||
http_listen_port: 9080
|
|
||||||
grpc_listen_port: 0
|
|
||||||
|
|
||||||
positions:
|
|
||||||
filename: /tmp/positions.yaml
|
|
||||||
|
|
||||||
clients:
|
|
||||||
- url: http://loki:3100/loki/api/v1/push
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: docker
|
|
||||||
docker_sd_configs:
|
|
||||||
- host: unix:///var/run/docker.sock
|
|
||||||
refresh_interval: 5s
|
|
||||||
relabel_configs:
|
|
||||||
- source_labels: ['__meta_docker_container_name']
|
|
||||||
regex: '/(.*)'
|
|
||||||
target_label: 'container'
|
|
||||||
- source_labels: ['__meta_docker_container_log_stream']
|
|
||||||
target_label: 'stream'
|
|
||||||
0
infrastructure/mosquitto/entrypoint.sh
Normal file → Executable file
0
infrastructure/mosquitto/entrypoint.sh
Normal file → Executable file
@@ -1,3 +0,0 @@
|
|||||||
[toolchain]
|
|
||||||
channel = "stable"
|
|
||||||
components = ["rustfmt", "clippy"]
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Run a Rust service with hot-reload (cargo-watch).
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\dev-watch.ps1 -Service backend-api
|
|
||||||
.\scripts\dev-watch.ps1 -Service telemetry-ingestor
|
|
||||||
#>
|
|
||||||
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[ValidateSet('backend-api','telemetry-ingestor','events-relay','json-generator','build-orchestrator','edge-agent','data-collector')]
|
|
||||||
[string]$Service
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Check if cargo-watch is installed
|
|
||||||
if (-not (Get-Command 'cargo-watch' -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Host "Installing cargo-watch..." -ForegroundColor Yellow
|
|
||||||
cargo install cargo-watch
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Starting $Service with hot-reload..." -ForegroundColor Green
|
|
||||||
cargo watch -x "run -p $Service"
|
|
||||||
@@ -44,5 +44,3 @@ semver = { workspace = true }
|
|||||||
axum-prometheus = "0.8"
|
axum-prometheus = "0.8"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
csv = "1.4.0"
|
csv = "1.4.0"
|
||||||
hmac = "0.12"
|
|
||||||
hex = "0.4"
|
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
-- Timescale compressed chunks have restrictions around DML and index changes.
|
|
||||||
-- Capture the compressed chunks up front, decompress them for this migration,
|
|
||||||
-- and recompress only the chunks that were compressed before the migration.
|
|
||||||
CREATE TEMP TABLE _telemetry_raw_compressed_chunks_before AS
|
|
||||||
SELECT format('%I.%I', chunk_schema, chunk_name)::regclass AS chunk
|
|
||||||
FROM timescaledb_information.chunks
|
|
||||||
WHERE hypertable_name = 'telemetry_raw'
|
|
||||||
AND is_compressed;
|
|
||||||
|
|
||||||
SELECT decompress_chunk(chunk, true)
|
|
||||||
FROM _telemetry_raw_compressed_chunks_before;
|
|
||||||
|
|
||||||
ALTER TABLE telemetry_raw
|
ALTER TABLE telemetry_raw
|
||||||
ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'SCADA',
|
ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'SCADA',
|
||||||
ADD COLUMN IF NOT EXISTS client_id TEXT;
|
ADD COLUMN IF NOT EXISTS client_id TEXT;
|
||||||
@@ -64,8 +52,3 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_telemetry_raw_time_asset_unique
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_telemetry_raw_project_source_time
|
CREATE INDEX IF NOT EXISTS idx_telemetry_raw_project_source_time
|
||||||
ON telemetry_raw (project_id, source, time DESC);
|
ON telemetry_raw (project_id, source, time DESC);
|
||||||
|
|
||||||
SELECT compress_chunk(chunk, true)
|
|
||||||
FROM _telemetry_raw_compressed_chunks_before;
|
|
||||||
|
|
||||||
DROP TABLE _telemetry_raw_compressed_chunks_before;
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
-- Existing deployments may already contain rows inserted by offline clients before
|
|
||||||
-- the idempotency constraint existed. Those rows can share the same per-asset
|
|
||||||
-- client id and would make the unique index fail. Only reclaim that narrow class
|
|
||||||
-- of duplicate idempotency keys: keep the earliest row deterministically and
|
|
||||||
-- delete later rows for the same non-empty (asset_id, details->>'client_id').
|
|
||||||
WITH duplicate_movement_client_ids AS (
|
|
||||||
SELECT
|
|
||||||
ctid,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY asset_id, details->>'client_id'
|
|
||||||
ORDER BY created_at NULLS LAST, id
|
|
||||||
) AS duplicate_position
|
|
||||||
FROM operation_movements
|
|
||||||
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
DELETE FROM operation_movements AS movement
|
|
||||||
USING duplicate_movement_client_ids AS duplicate
|
|
||||||
WHERE movement.ctid = duplicate.ctid
|
|
||||||
AND duplicate.duplicate_position > 1;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_operation_movements_asset_client_id_unique
|
|
||||||
ON operation_movements (asset_id, (details->>'client_id'))
|
|
||||||
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
-- Customer commercial profile foundation.
|
|
||||||
-- Rollback boundary: later slices can ignore these additive tables and continue
|
|
||||||
-- reading legacy subscriptions. Existing subscription rows are not modified.
|
|
||||||
|
|
||||||
ALTER TABLE access_requests
|
|
||||||
DROP CONSTRAINT IF EXISTS access_requests_status_check;
|
|
||||||
|
|
||||||
ALTER TABLE access_requests
|
|
||||||
ADD CONSTRAINT access_requests_status_check
|
|
||||||
CHECK (status IN ('pending', 'pending_classification', 'in_review', 'approved', 'rejected'));
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS customer_commercial_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
access_request_id UUID REFERENCES access_requests(id) ON DELETE SET NULL,
|
|
||||||
project_id UUID REFERENCES edge_projects(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR(30) NOT NULL DEFAULT 'draft'
|
|
||||||
CHECK (status IN ('draft', 'in_review', 'approved', 'active', 'suspended', 'archived')),
|
|
||||||
plan VARCHAR(30) NOT NULL DEFAULT 'free'
|
|
||||||
CHECK (plan IN ('free', 'operative', 'enterprise')),
|
|
||||||
payment_method VARCHAR(40)
|
|
||||||
CHECK (payment_method IS NULL OR payment_method IN ('none', 'bank_transfer', 'manual_invoice', 'enterprise_contract')),
|
|
||||||
payment_terms_days INT CHECK (payment_terms_days IS NULL OR payment_terms_days >= 0),
|
|
||||||
credit_limit_cop BIGINT CHECK (credit_limit_cop IS NULL OR credit_limit_cop >= 0),
|
|
||||||
tg_limit NUMERIC(12, 2) CHECK (tg_limit IS NULL OR tg_limit >= 0),
|
|
||||||
pbase_cop BIGINT NOT NULL DEFAULT 110000 CHECK (pbase_cop > 0),
|
|
||||||
discount_k NUMERIC(6, 4) NOT NULL DEFAULT 0.1000 CHECK (discount_k >= 0),
|
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'COP',
|
|
||||||
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
||||||
effective_to DATE CHECK (effective_to IS NULL OR effective_to >= effective_from),
|
|
||||||
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
approved_at TIMESTAMPTZ,
|
|
||||||
enterprise_annual_cop BIGINT CHECK (enterprise_annual_cop IS NULL OR enterprise_annual_cop >= 0),
|
|
||||||
enterprise_monthly_cop BIGINT CHECK (enterprise_monthly_cop IS NULL OR enterprise_monthly_cop >= 0),
|
|
||||||
enterprise_override_reason TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CHECK (access_request_id IS NOT NULL OR project_id IS NOT NULL)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_commercial_profiles_access_request
|
|
||||||
ON customer_commercial_profiles(access_request_id)
|
|
||||||
WHERE access_request_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_commercial_profiles_project
|
|
||||||
ON customer_commercial_profiles(project_id)
|
|
||||||
WHERE project_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profiles_status
|
|
||||||
ON customer_commercial_profiles(status, effective_from DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profiles_plan
|
|
||||||
ON customer_commercial_profiles(plan);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS customer_commercial_profile_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
commercial_profile_id UUID NOT NULL REFERENCES customer_commercial_profiles(id) ON DELETE CASCADE,
|
|
||||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
change_reason TEXT NOT NULL,
|
|
||||||
changed_fields TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
||||||
before_json JSONB NOT NULL DEFAULT '{}'::JSONB,
|
|
||||||
after_json JSONB NOT NULL DEFAULT '{}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profile_versions_profile
|
|
||||||
ON customer_commercial_profile_versions(commercial_profile_id, changed_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profile_versions_actor
|
|
||||||
ON customer_commercial_profile_versions(actor_user_id, changed_at DESC)
|
|
||||||
WHERE actor_user_id IS NOT NULL;
|
|
||||||
|
|
||||||
INSERT INTO customer_commercial_profiles (
|
|
||||||
project_id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k,
|
|
||||||
effective_from,
|
|
||||||
notes
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
s.project_id,
|
|
||||||
CASE WHEN s.status = 'suspended' THEN 'suspended' ELSE 'active' END,
|
|
||||||
CASE WHEN s.tier = 'enterprise' THEN 'enterprise' ELSE 'operative' END,
|
|
||||||
'manual_invoice',
|
|
||||||
30,
|
|
||||||
s.tag_limit::NUMERIC,
|
|
||||||
COALESCE(NULLIF(s.price_per_tag, 90000), 110000),
|
|
||||||
0.1000,
|
|
||||||
s.current_period_start::DATE,
|
|
||||||
'Backfilled from legacy subscription during commercial profile rollout.'
|
|
||||||
FROM subscriptions s
|
|
||||||
WHERE s.status IN ('active', 'trial', 'suspended')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS user_well_access (
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
|
||||||
well_asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
PRIMARY KEY (user_id, well_asset_id),
|
|
||||||
CONSTRAINT user_well_access_project_well_unique UNIQUE (user_id, project_id, well_asset_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_well_access_user_project
|
|
||||||
ON user_well_access (user_id, project_id)
|
|
||||||
WHERE is_active = true;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_well_access_well
|
|
||||||
ON user_well_access (well_asset_id)
|
|
||||||
WHERE is_active = true;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION validate_user_well_access_well()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM edge_assets ea
|
|
||||||
WHERE ea.id = NEW.well_asset_id
|
|
||||||
AND ea.project_id = NEW.project_id
|
|
||||||
AND ea.asset_type = 'POZO'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'user_well_access.well_asset_id must reference a POZO in the same project';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_validate_user_well_access_well ON user_well_access;
|
|
||||||
CREATE TRIGGER trg_validate_user_well_access_well
|
|
||||||
BEFORE INSERT OR UPDATE ON user_well_access
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION validate_user_well_access_well();
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
-- Migration to implement Row Level Security (RLS) and multi-tenancy constraints
|
|
||||||
-- at the database layer.
|
|
||||||
|
|
||||||
-- 1. Insert the 'superadmin' platform role with read-only statistics permissions
|
|
||||||
-- Note: 'superadmin' already exists in the system database with standard permissions,
|
|
||||||
-- this ensures it is initialized if it's a blank setup.
|
|
||||||
INSERT INTO roles (name, permissions) VALUES
|
|
||||||
('superadmin', '{"PLATFORM_ACCESS": true, "SUPER_STATS_ONLY": true}'::jsonb)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- 2. Create helper functions to extract session context
|
|
||||||
CREATE OR REPLACE FUNCTION get_current_user_id() RETURNS UUID AS $$
|
|
||||||
DECLARE
|
|
||||||
user_id_str TEXT;
|
|
||||||
BEGIN
|
|
||||||
user_id_str := current_setting('app.current_user_id', true);
|
|
||||||
IF user_id_str IS NULL OR user_id_str = '' THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
RETURN user_id_str::uuid;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION is_superadmin() RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
current_uid UUID;
|
|
||||||
is_sa BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
current_uid := get_current_user_id();
|
|
||||||
IF current_uid IS NULL THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM users u
|
|
||||||
JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.id = current_uid
|
|
||||||
AND r.name = 'superadmin'
|
|
||||||
) INTO is_sa;
|
|
||||||
|
|
||||||
RETURN is_sa;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
-- 3. Enable RLS on multi-tenant tables
|
|
||||||
ALTER TABLE edge_projects ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE edge_assets ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE edge_devices ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE edge_variables ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE operation_movements ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE operations_downtime ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE well_tests ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE lab_results ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE meter_readings ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE alert_rules ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- 4. Create RLS policies for edge_projects
|
|
||||||
DROP POLICY IF EXISTS select_edge_projects ON edge_projects;
|
|
||||||
CREATE POLICY select_edge_projects ON edge_projects
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = edge_projects.id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_edge_projects ON edge_projects;
|
|
||||||
CREATE POLICY modify_edge_projects ON edge_projects
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = edge_projects.id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = edge_projects.id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5. Helper procedure to create standard policies for child tables with 'project_id' column
|
|
||||||
CREATE OR REPLACE PROCEDURE create_child_table_rls_policies(table_name TEXT) AS $$
|
|
||||||
BEGIN
|
|
||||||
EXECUTE format('
|
|
||||||
DROP POLICY IF EXISTS select_%I ON %I;
|
|
||||||
CREATE POLICY select_%I ON %I
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = %I.project_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
', table_name, table_name, table_name, table_name, table_name);
|
|
||||||
|
|
||||||
EXECUTE format('
|
|
||||||
DROP POLICY IF EXISTS modify_%I ON %I;
|
|
||||||
CREATE POLICY modify_%I ON %I
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = %I.project_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = %I.project_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
', table_name, table_name, table_name, table_name, table_name, table_name);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- 6. Apply standard RLS policies to child tables with direct 'project_id'
|
|
||||||
CALL create_child_table_rls_policies('edge_assets');
|
|
||||||
CALL create_child_table_rls_policies('alert_rules');
|
|
||||||
|
|
||||||
-- Clean up helper procedure
|
|
||||||
DROP PROCEDURE create_child_table_rls_policies(TEXT);
|
|
||||||
|
|
||||||
-- 7. Custom RLS policies for tables referencing asset_id (no direct project_id)
|
|
||||||
-- edge_devices
|
|
||||||
DROP POLICY IF EXISTS select_edge_devices ON edge_devices;
|
|
||||||
CREATE POLICY select_edge_devices ON edge_devices
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = edge_devices.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_edge_devices ON edge_devices;
|
|
||||||
CREATE POLICY modify_edge_devices ON edge_devices
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = edge_devices.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = edge_devices.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- operation_movements
|
|
||||||
DROP POLICY IF EXISTS select_operation_movements ON operation_movements;
|
|
||||||
CREATE POLICY select_operation_movements ON operation_movements
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operation_movements.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_operation_movements ON operation_movements;
|
|
||||||
CREATE POLICY modify_operation_movements ON operation_movements
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operation_movements.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operation_movements.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- operations_downtime
|
|
||||||
DROP POLICY IF EXISTS select_operations_downtime ON operations_downtime;
|
|
||||||
CREATE POLICY select_operations_downtime ON operations_downtime
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operations_downtime.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_operations_downtime ON operations_downtime;
|
|
||||||
CREATE POLICY modify_operations_downtime ON operations_downtime
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operations_downtime.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operations_downtime.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- well_tests
|
|
||||||
DROP POLICY IF EXISTS select_well_tests ON well_tests;
|
|
||||||
CREATE POLICY select_well_tests ON well_tests
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = well_tests.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_well_tests ON well_tests;
|
|
||||||
CREATE POLICY modify_well_tests ON well_tests
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = well_tests.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = well_tests.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- lab_results
|
|
||||||
DROP POLICY IF EXISTS select_lab_results ON lab_results;
|
|
||||||
CREATE POLICY select_lab_results ON lab_results
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = lab_results.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_lab_results ON lab_results;
|
|
||||||
CREATE POLICY modify_lab_results ON lab_results
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = lab_results.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = lab_results.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- meter_readings
|
|
||||||
DROP POLICY IF EXISTS select_meter_readings ON meter_readings;
|
|
||||||
CREATE POLICY select_meter_readings ON meter_readings
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = meter_readings.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_meter_readings ON meter_readings;
|
|
||||||
CREATE POLICY modify_meter_readings ON meter_readings
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = meter_readings.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = meter_readings.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8. Custom RLS policies for edge_variables (referencing device_id -> asset_id)
|
|
||||||
DROP POLICY IF EXISTS select_edge_variables ON edge_variables;
|
|
||||||
CREATE POLICY select_edge_variables ON edge_variables
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_devices ed
|
|
||||||
JOIN edge_assets ea ON ea.id = ed.asset_id
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ed.id = edge_variables.device_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_edge_variables ON edge_variables;
|
|
||||||
CREATE POLICY modify_edge_variables ON edge_variables
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_devices ed
|
|
||||||
JOIN edge_assets ea ON ea.id = ed.asset_id
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ed.id = edge_variables.device_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_devices ed
|
|
||||||
JOIN edge_assets ea ON ea.id = ed.asset_id
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ed.id = edge_variables.device_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
-- =============================================================================
|
|
||||||
-- OMNIOIL — PERFORMANCE INDEXES (v2026-07-07)
|
|
||||||
-- =============================================================================
|
|
||||||
-- Índices compuestos faltantes identificados mediante análisis de queries.
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- 1. Outbox polling: events-relay consulta cada segundo por eventos PENDING
|
|
||||||
-- ordenados por created_at (LIMIT 50). Sin índice, escanea toda la tabla.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_domain_outbox_pending
|
|
||||||
ON domain_outbox (status, created_at)
|
|
||||||
WHERE status = 'PENDING';
|
|
||||||
|
|
||||||
-- 2. Watchdog unhealthy agents: busca alarmas AGENT_OFFLINE recientes.
|
|
||||||
-- El índice existente idx_telemetry_alarms_project no es óptimo porque
|
|
||||||
-- filtra por alarm_type y timestamp, no por project_id.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_type_time
|
|
||||||
ON telemetry_alarms (alarm_type, timestamp DESC);
|
|
||||||
|
|
||||||
-- 3. Cooldown de alarmas: telemetry-ingestor verifica duplicados por
|
|
||||||
-- (asset_id, variable_alias, alarm_type) con timestamp > NOW() - 15min.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_cooldown
|
|
||||||
ON telemetry_alarms (asset_id, variable_alias, alarm_type, timestamp DESC);
|
|
||||||
|
|
||||||
-- 4. Cleanup agent_logs: DELETE masivo por timestamp.
|
|
||||||
-- El índice existente idx_agent_logs_project no cubre timestamp sola.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_logs_timestamp
|
|
||||||
ON agent_logs (timestamp DESC);
|
|
||||||
|
|
||||||
-- 5. Watchdog unhealthy agents: filtro compuesto sobre edge_projects.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_projects_watchdog
|
|
||||||
ON edge_projects (is_active, installer_status, contract_number)
|
|
||||||
WHERE is_active = true
|
|
||||||
AND installer_status = 'COMPLETED'
|
|
||||||
AND btrim(contract_number) <> '';
|
|
||||||
|
|
||||||
-- 6. Watchdog calibration: busca assets activos próximos a vencer.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_assets_calibration
|
|
||||||
ON edge_assets (project_id, is_active, next_calibration_date)
|
|
||||||
WHERE is_active = true;
|
|
||||||
@@ -99,19 +99,6 @@ pub async fn log(pool: &PgPool, entry: AuditEntry<'_>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commercial_profile_change_metadata(
|
|
||||||
before_json: serde_json::Value,
|
|
||||||
after_json: serde_json::Value,
|
|
||||||
reason: &str,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"domain": "customer_commercial_profiles",
|
|
||||||
"reason": reason,
|
|
||||||
"before": before_json,
|
|
||||||
"after": after_json,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extrae la IP del cliente de los headers de la request.
|
/// Extrae la IP del cliente de los headers de la request.
|
||||||
pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
|
pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
|
||||||
req.headers()
|
req.headers()
|
||||||
@@ -126,37 +113,3 @@ pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#[test]
|
|
||||||
fn commercial_profile_audit_metadata_preserves_before_after_and_reason() {
|
|
||||||
let before = serde_json::json!({
|
|
||||||
"status": "in_review",
|
|
||||||
"payment_terms_days": 15,
|
|
||||||
"effective_from": "2026-06-01"
|
|
||||||
});
|
|
||||||
let after = serde_json::json!({
|
|
||||||
"status": "approved",
|
|
||||||
"payment_terms_days": 30,
|
|
||||||
"effective_from": "2026-07-01",
|
|
||||||
"approved_by": "44444444-4444-4444-4444-444444444444"
|
|
||||||
});
|
|
||||||
|
|
||||||
let metadata = super::commercial_profile_change_metadata(
|
|
||||||
before.clone(),
|
|
||||||
after.clone(),
|
|
||||||
"Terms approved for July rollout",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(metadata["domain"], "customer_commercial_profiles");
|
|
||||||
assert_eq!(metadata["reason"], "Terms approved for July rollout");
|
|
||||||
assert_eq!(metadata["before"], before);
|
|
||||||
assert_eq!(metadata["after"], after);
|
|
||||||
assert_eq!(metadata["after"]["effective_from"], "2026-07-01");
|
|
||||||
assert_eq!(
|
|
||||||
metadata["after"]["approved_by"],
|
|
||||||
"44444444-4444-4444-4444-444444444444"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -390,16 +390,10 @@ pub fn role_satisfies_platform_admin_claims(role: &str) -> bool {
|
|||||||
|
|
||||||
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
|
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
|
||||||
let email = claims.email.trim().to_ascii_lowercase();
|
let email = claims.email.trim().to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
let reviewers = std::env::var("ACCESS_REQUEST_REVIEWER_EMAILS")
|
email.as_str(),
|
||||||
.unwrap_or_default();
|
"w.farfan@omnioil.app" | "h.ortegon@omnioil.app"
|
||||||
let reviewer_list: Vec<String> = reviewers
|
) {
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_ascii_lowercase())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if reviewer_list.contains(&email.to_ascii_lowercase()) {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub const DEFAULT_OPERATIVE_K: f64 = 0.10;
|
|
||||||
pub const DEFAULT_OPERATIVE_PBASE_COP: i64 = 110_000;
|
|
||||||
pub const OPERATIVE_LF_EQUIVALENT_TG: f64 = 0.4;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
|
|
||||||
pub struct OperativeBillingInput {
|
|
||||||
pub hf_tags: u32,
|
|
||||||
pub lf_tags: u32,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub k: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
pub struct OperativeBillingBreakdown {
|
|
||||||
pub hf_tags: u32,
|
|
||||||
pub lf_tags: u32,
|
|
||||||
pub equivalent_tg: f64,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub k: f64,
|
|
||||||
pub annual_cop: i64,
|
|
||||||
pub monthly_cop: i64,
|
|
||||||
pub currency: &'static str,
|
|
||||||
pub rounding_policy: &'static str,
|
|
||||||
pub effective_hf_price_cop: i64,
|
|
||||||
pub effective_lf_price_cop: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum BillingRulesError {
|
|
||||||
NonPositivePbase,
|
|
||||||
NegativeDiscountExponent,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn calculate_operative_billing_with_defaults(
|
|
||||||
hf_tags: u32,
|
|
||||||
lf_tags: u32,
|
|
||||||
) -> Result<OperativeBillingBreakdown, BillingRulesError> {
|
|
||||||
calculate_operative_billing(OperativeBillingInput {
|
|
||||||
hf_tags,
|
|
||||||
lf_tags,
|
|
||||||
pbase_cop: DEFAULT_OPERATIVE_PBASE_COP,
|
|
||||||
k: DEFAULT_OPERATIVE_K,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn calculate_operative_billing(
|
|
||||||
input: OperativeBillingInput,
|
|
||||||
) -> Result<OperativeBillingBreakdown, BillingRulesError> {
|
|
||||||
if input.pbase_cop <= 0 {
|
|
||||||
return Err(BillingRulesError::NonPositivePbase);
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.k < 0.0 {
|
|
||||||
return Err(BillingRulesError::NegativeDiscountExponent);
|
|
||||||
}
|
|
||||||
|
|
||||||
let equivalent_tg = input.hf_tags as f64 + (input.lf_tags as f64 * OPERATIVE_LF_EQUIVALENT_TG);
|
|
||||||
|
|
||||||
if equivalent_tg == 0.0 {
|
|
||||||
return Ok(OperativeBillingBreakdown {
|
|
||||||
hf_tags: input.hf_tags,
|
|
||||||
lf_tags: input.lf_tags,
|
|
||||||
equivalent_tg,
|
|
||||||
pbase_cop: input.pbase_cop,
|
|
||||||
k: input.k,
|
|
||||||
annual_cop: 0,
|
|
||||||
monthly_cop: 0,
|
|
||||||
currency: "COP",
|
|
||||||
rounding_policy: "nearest peso",
|
|
||||||
effective_hf_price_cop: 0,
|
|
||||||
effective_lf_price_cop: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let annual_cop =
|
|
||||||
round_cop(equivalent_tg * input.pbase_cop as f64 * equivalent_tg.powf(-input.k));
|
|
||||||
let monthly_cop = round_cop(annual_cop as f64 / 12.0);
|
|
||||||
let effective_hf_price_cop = if input.hf_tags > 0 {
|
|
||||||
round_cop(annual_cop as f64 / equivalent_tg)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let effective_lf_price_cop = if input.lf_tags > 0 {
|
|
||||||
round_cop((annual_cop as f64 / equivalent_tg) * OPERATIVE_LF_EQUIVALENT_TG)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(OperativeBillingBreakdown {
|
|
||||||
hf_tags: input.hf_tags,
|
|
||||||
lf_tags: input.lf_tags,
|
|
||||||
equivalent_tg,
|
|
||||||
pbase_cop: input.pbase_cop,
|
|
||||||
k: input.k,
|
|
||||||
annual_cop,
|
|
||||||
monthly_cop,
|
|
||||||
currency: "COP",
|
|
||||||
rounding_policy: "nearest peso",
|
|
||||||
effective_hf_price_cop,
|
|
||||||
effective_lf_price_cop,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn round_cop(value: f64) -> i64 {
|
|
||||||
value.round() as i64
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,8 @@
|
|||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub type DbPool = PgPool;
|
pub type DbPool = PgPool;
|
||||||
|
|
||||||
pub fn default_pool_options() -> PgPoolOptions {
|
|
||||||
PgPoolOptions::new()
|
|
||||||
.max_connections(
|
|
||||||
env::var("DB_MAX_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
)
|
|
||||||
.min_connections(
|
|
||||||
env::var("DB_MIN_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)
|
|
||||||
.idle_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(600),
|
|
||||||
))
|
|
||||||
.max_lifetime(Duration::from_secs(
|
|
||||||
env::var("DB_MAX_LIFETIME_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1800),
|
|
||||||
))
|
|
||||||
.acquire_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_ACQUIRE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
||||||
|
|
||||||
@@ -45,7 +10,9 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
|
|||||||
let max_retries = 10;
|
let max_retries = 10;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match default_pool_options()
|
match PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(3))
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, NaiveDate, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -83,71 +83,6 @@ pub struct RejectAccessRequestPayload {
|
|||||||
pub reason: String,
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct CommercialClassificationPayload {
|
|
||||||
pub plan: Option<String>,
|
|
||||||
pub payment_method: Option<String>,
|
|
||||||
pub payment_terms_days: Option<i32>,
|
|
||||||
pub credit_limit_cop: Option<i64>,
|
|
||||||
pub tg_limit: Option<f64>,
|
|
||||||
pub pbase_cop: Option<i64>,
|
|
||||||
pub discount_k: Option<f64>,
|
|
||||||
pub effective_from: Option<NaiveDate>,
|
|
||||||
pub enterprise_annual_cop: Option<i64>,
|
|
||||||
pub enterprise_monthly_cop: Option<i64>,
|
|
||||||
pub enterprise_override_reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
|
||||||
pub struct CommercialProfileRow {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub access_request_id: Option<Uuid>,
|
|
||||||
pub project_id: Option<Uuid>,
|
|
||||||
pub status: String,
|
|
||||||
pub plan: String,
|
|
||||||
pub payment_method: Option<String>,
|
|
||||||
pub payment_terms_days: Option<i32>,
|
|
||||||
pub credit_limit_cop: Option<i64>,
|
|
||||||
pub tg_limit: Option<f64>,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub discount_k: f64,
|
|
||||||
pub currency: String,
|
|
||||||
pub effective_from: NaiveDate,
|
|
||||||
pub effective_to: Option<NaiveDate>,
|
|
||||||
pub approved_by: Option<Uuid>,
|
|
||||||
pub approved_at: Option<DateTime<Utc>>,
|
|
||||||
pub enterprise_annual_cop: Option<i64>,
|
|
||||||
pub enterprise_monthly_cop: Option<i64>,
|
|
||||||
pub enterprise_override_reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub profile_complete: bool,
|
|
||||||
pub missing_fields: Vec<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq))]
|
|
||||||
struct ClassificationPersistenceIntent {
|
|
||||||
missing_fields: Vec<&'static str>,
|
|
||||||
profile_status: &'static str,
|
|
||||||
request_status: &'static str,
|
|
||||||
approved_by: Option<Uuid>,
|
|
||||||
approved_at_should_be_set: bool,
|
|
||||||
effective_from: NaiveDate,
|
|
||||||
change_reason: String,
|
|
||||||
changed_fields: Vec<&'static str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
struct ClassificationPersistenceStatements {
|
|
||||||
profile_upsert: &'static str,
|
|
||||||
version_insert: &'static str,
|
|
||||||
request_update: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
pub struct AccessRequestRow {
|
pub struct AccessRequestRow {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -177,7 +112,6 @@ pub struct AccessRequestRow {
|
|||||||
pub invitation_expires_at: Option<DateTime<Utc>>,
|
pub invitation_expires_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_consumed_at: Option<DateTime<Utc>>,
|
pub invitation_consumed_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_revoked_at: Option<DateTime<Utc>>,
|
pub invitation_revoked_at: Option<DateTime<Utc>>,
|
||||||
pub commercial_profile: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCESS_REQUEST_SELECT: &str = r#"
|
const ACCESS_REQUEST_SELECT: &str = r#"
|
||||||
@@ -193,8 +127,7 @@ const ACCESS_REQUEST_SELECT: &str = r#"
|
|||||||
sub.trial_ends_at AS trial_ends_at,
|
sub.trial_ends_at AS trial_ends_at,
|
||||||
inv.expires_at AS invitation_expires_at,
|
inv.expires_at AS invitation_expires_at,
|
||||||
inv.consumed_at AS invitation_consumed_at,
|
inv.consumed_at AS invitation_consumed_at,
|
||||||
inv.revoked_at AS invitation_revoked_at,
|
inv.revoked_at AS invitation_revoked_at
|
||||||
profile.commercial_profile AS commercial_profile
|
|
||||||
FROM access_requests ar
|
FROM access_requests ar
|
||||||
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
||||||
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
||||||
@@ -212,195 +145,17 @@ const ACCESS_REQUEST_SELECT: &str = r#"
|
|||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) inv ON true
|
) inv ON true
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT jsonb_build_object(
|
|
||||||
'id', ccp.id,
|
|
||||||
'access_request_id', ccp.access_request_id,
|
|
||||||
'project_id', ccp.project_id,
|
|
||||||
'status', ccp.status,
|
|
||||||
'plan', ccp.plan,
|
|
||||||
'payment_method', ccp.payment_method,
|
|
||||||
'payment_terms_days', ccp.payment_terms_days,
|
|
||||||
'credit_limit_cop', ccp.credit_limit_cop,
|
|
||||||
'tg_limit', ccp.tg_limit,
|
|
||||||
'pbase_cop', ccp.pbase_cop,
|
|
||||||
'discount_k', ccp.discount_k,
|
|
||||||
'currency', ccp.currency,
|
|
||||||
'effective_from', ccp.effective_from,
|
|
||||||
'effective_to', ccp.effective_to,
|
|
||||||
'enterprise_annual_cop', ccp.enterprise_annual_cop,
|
|
||||||
'enterprise_monthly_cop', ccp.enterprise_monthly_cop,
|
|
||||||
'enterprise_override_reason', ccp.enterprise_override_reason,
|
|
||||||
'profile_complete', (
|
|
||||||
ccp.plan IS NOT NULL
|
|
||||||
AND ccp.payment_method IS NOT NULL
|
|
||||||
AND (
|
|
||||||
ccp.plan = 'free'
|
|
||||||
OR (
|
|
||||||
ccp.payment_terms_days IS NOT NULL
|
|
||||||
AND ccp.tg_limit IS NOT NULL
|
|
||||||
AND ccp.effective_from IS NOT NULL
|
|
||||||
AND (
|
|
||||||
ccp.plan != 'enterprise'
|
|
||||||
OR (
|
|
||||||
ccp.enterprise_annual_cop IS NOT NULL
|
|
||||||
AND ccp.enterprise_monthly_cop IS NOT NULL
|
|
||||||
AND NULLIF(BTRIM(COALESCE(ccp.enterprise_override_reason, '')), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) AS commercial_profile
|
|
||||||
FROM customer_commercial_profiles ccp
|
|
||||||
WHERE ccp.access_request_id = ar.id
|
|
||||||
OR (ar.provisioned_project_id IS NOT NULL AND ccp.project_id = ar.provisioned_project_id)
|
|
||||||
ORDER BY ccp.updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) profile ON true
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const CLASSIFICATION_PROFILE_UPSERT_SQL: &str = r#"INSERT INTO customer_commercial_profiles (
|
|
||||||
access_request_id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
credit_limit_cop,
|
|
||||||
tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k,
|
|
||||||
effective_from,
|
|
||||||
approved_by,
|
|
||||||
approved_at,
|
|
||||||
enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason,
|
|
||||||
notes
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
COALESCE($3, 'free'),
|
|
||||||
$4,
|
|
||||||
$5,
|
|
||||||
$6,
|
|
||||||
$7,
|
|
||||||
COALESCE($8, 110000),
|
|
||||||
COALESCE($9, 0.1000),
|
|
||||||
COALESCE($10, CURRENT_DATE),
|
|
||||||
CASE WHEN $11 THEN $12 ELSE NULL END,
|
|
||||||
CASE WHEN $11 THEN NOW() ELSE NULL END,
|
|
||||||
$13,
|
|
||||||
$14,
|
|
||||||
$15,
|
|
||||||
$16
|
|
||||||
)
|
|
||||||
ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL
|
|
||||||
DO UPDATE SET
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
plan = EXCLUDED.plan,
|
|
||||||
payment_method = EXCLUDED.payment_method,
|
|
||||||
payment_terms_days = EXCLUDED.payment_terms_days,
|
|
||||||
credit_limit_cop = EXCLUDED.credit_limit_cop,
|
|
||||||
tg_limit = EXCLUDED.tg_limit,
|
|
||||||
pbase_cop = EXCLUDED.pbase_cop,
|
|
||||||
discount_k = EXCLUDED.discount_k,
|
|
||||||
effective_from = EXCLUDED.effective_from,
|
|
||||||
approved_by = EXCLUDED.approved_by,
|
|
||||||
approved_at = EXCLUDED.approved_at,
|
|
||||||
enterprise_annual_cop = EXCLUDED.enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop = EXCLUDED.enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason = EXCLUDED.enterprise_override_reason,
|
|
||||||
notes = EXCLUDED.notes,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id"#;
|
|
||||||
|
|
||||||
const COMMERCIAL_PROFILE_VERSION_INSERT_SQL: &str = r#"INSERT INTO customer_commercial_profile_versions (
|
|
||||||
commercial_profile_id,
|
|
||||||
actor_user_id,
|
|
||||||
change_reason,
|
|
||||||
changed_fields,
|
|
||||||
before_json,
|
|
||||||
after_json
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)"#;
|
|
||||||
|
|
||||||
const CLASSIFICATION_REQUEST_STATUS_UPDATE_SQL: &str = r#"UPDATE access_requests
|
|
||||||
SET status = CASE WHEN $2 THEN 'approved' ELSE 'pending_classification' END,
|
|
||||||
reviewed_by = $3,
|
|
||||||
reviewed_at = NOW(),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1"#;
|
|
||||||
|
|
||||||
fn validate_status(status: &str) -> Result<(), AppError> {
|
fn validate_status(status: &str) -> Result<(), AppError> {
|
||||||
match status {
|
match status {
|
||||||
"pending" | "in_review" | "approved" | "rejected" => Ok(()),
|
"pending" | "in_review" | "approved" | "rejected" => Ok(()),
|
||||||
"pending_classification" => Ok(()),
|
|
||||||
_ => Err(AppError::BadRequest(
|
_ => Err(AppError::BadRequest(
|
||||||
"Estado de solicitud inválido".to_string(),
|
"Estado de solicitud inválido".to_string(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_can_be_provisioned(status: &str) -> bool {
|
|
||||||
status == "approved"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classification_missing_fields(
|
|
||||||
plan: Option<&str>,
|
|
||||||
payment_method: Option<&str>,
|
|
||||||
payment_terms_days: Option<i32>,
|
|
||||||
tg_limit: Option<f64>,
|
|
||||||
pbase_cop: Option<i64>,
|
|
||||||
discount_k: Option<f64>,
|
|
||||||
effective_from: Option<NaiveDate>,
|
|
||||||
enterprise: Option<(Option<i64>, Option<i64>, Option<&str>)>,
|
|
||||||
) -> Vec<&'static str> {
|
|
||||||
let mut missing = Vec::new();
|
|
||||||
|
|
||||||
if plan.is_none_or(|value| value.trim().is_empty()) {
|
|
||||||
missing.push("plan");
|
|
||||||
}
|
|
||||||
if payment_method.is_none_or(|value| value.trim().is_empty()) {
|
|
||||||
missing.push("payment_method");
|
|
||||||
}
|
|
||||||
let is_free = matches!(plan, Some("free"));
|
|
||||||
|
|
||||||
if !is_free {
|
|
||||||
if payment_terms_days.is_none() {
|
|
||||||
missing.push("payment_terms_days");
|
|
||||||
}
|
|
||||||
if tg_limit.is_none() {
|
|
||||||
missing.push("tg_limit");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pbase_cop.is_some_and(|value| value <= 0) {
|
|
||||||
missing.push("pbase_cop");
|
|
||||||
}
|
|
||||||
if discount_k.is_some_and(|value| value < 0.0) {
|
|
||||||
missing.push("discount_k");
|
|
||||||
}
|
|
||||||
if !is_free && effective_from.is_none() {
|
|
||||||
missing.push("effective_from");
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(plan, Some("enterprise")) {
|
|
||||||
let (annual, monthly, reason) = enterprise.unwrap_or((None, None, None));
|
|
||||||
if annual.is_none() {
|
|
||||||
missing.push("enterprise_annual_cop");
|
|
||||||
}
|
|
||||||
if monthly.is_none() {
|
|
||||||
missing.push("enterprise_monthly_cop");
|
|
||||||
}
|
|
||||||
if reason.is_none_or(|value| value.trim().is_empty()) {
|
|
||||||
missing.push("enterprise_override_reason");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
missing
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_access_request(pool: &PgPool, id: Uuid) -> Result<AccessRequestRow, AppError> {
|
async fn fetch_access_request(pool: &PgPool, id: Uuid) -> Result<AccessRequestRow, AppError> {
|
||||||
let query = format!("{} WHERE ar.id = $1", ACCESS_REQUEST_SELECT);
|
let query = format!("{} WHERE ar.id = $1", ACCESS_REQUEST_SELECT);
|
||||||
|
|
||||||
@@ -411,169 +166,6 @@ async fn fetch_access_request(pool: &PgPool, id: Uuid) -> Result<AccessRequestRo
|
|||||||
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))
|
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commercial_profile_select() -> &'static str {
|
|
||||||
r#"SELECT id,
|
|
||||||
access_request_id,
|
|
||||||
project_id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
credit_limit_cop,
|
|
||||||
tg_limit::float8 AS tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k::float8 AS discount_k,
|
|
||||||
currency,
|
|
||||||
effective_from,
|
|
||||||
effective_to,
|
|
||||||
approved_by,
|
|
||||||
approved_at,
|
|
||||||
enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason,
|
|
||||||
notes,
|
|
||||||
(
|
|
||||||
plan IS NOT NULL
|
|
||||||
AND payment_method IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan = 'free'
|
|
||||||
OR (
|
|
||||||
payment_terms_days IS NOT NULL
|
|
||||||
AND tg_limit IS NOT NULL
|
|
||||||
AND effective_from IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan != 'enterprise'
|
|
||||||
OR (
|
|
||||||
enterprise_annual_cop IS NOT NULL
|
|
||||||
AND enterprise_monthly_cop IS NOT NULL
|
|
||||||
AND NULLIF(BTRIM(COALESCE(enterprise_override_reason, '')), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) AS profile_complete,
|
|
||||||
ARRAY_REMOVE(ARRAY[
|
|
||||||
CASE WHEN plan IS NULL THEN 'plan' END,
|
|
||||||
CASE WHEN payment_method IS NULL THEN 'payment_method' END,
|
|
||||||
CASE WHEN plan != 'free' AND payment_terms_days IS NULL THEN 'payment_terms_days' END,
|
|
||||||
CASE WHEN plan != 'free' AND tg_limit IS NULL THEN 'tg_limit' END,
|
|
||||||
CASE WHEN plan != 'free' AND effective_from IS NULL THEN 'effective_from' END,
|
|
||||||
CASE WHEN plan = 'enterprise' AND enterprise_annual_cop IS NULL THEN 'enterprise_annual_cop' END,
|
|
||||||
CASE WHEN plan = 'enterprise' AND enterprise_monthly_cop IS NULL THEN 'enterprise_monthly_cop' END,
|
|
||||||
CASE WHEN plan = 'enterprise' AND NULLIF(BTRIM(COALESCE(enterprise_override_reason, '')), '') IS NULL THEN 'enterprise_override_reason' END
|
|
||||||
], NULL)::TEXT[] AS missing_fields,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
FROM customer_commercial_profiles"#
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_commercial_profile_by_request(
|
|
||||||
pool: &PgPool,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<CommercialProfileRow, AppError> {
|
|
||||||
let query = format!(
|
|
||||||
"{} WHERE access_request_id = $1 ORDER BY updated_at DESC LIMIT 1",
|
|
||||||
commercial_profile_select()
|
|
||||||
);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, CommercialProfileRow>(&query)
|
|
||||||
.bind(request_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Perfil comercial".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_commercial_profile_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
profile_id: Uuid,
|
|
||||||
) -> Result<CommercialProfileRow, AppError> {
|
|
||||||
let query = format!("{} WHERE id = $1", commercial_profile_select());
|
|
||||||
|
|
||||||
sqlx::query_as::<_, CommercialProfileRow>(&query)
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Perfil comercial".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_classification_payload(
|
|
||||||
payload: &CommercialClassificationPayload,
|
|
||||||
) -> Result<Vec<&'static str>, AppError> {
|
|
||||||
let plan = payload.plan.as_deref().map(str::trim);
|
|
||||||
if let Some(plan) = plan {
|
|
||||||
if !matches!(plan, "free" | "operative" | "enterprise") {
|
|
||||||
return Err(AppError::BadRequest("Plan comercial inválido".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(payment_method) = payload.payment_method.as_deref().map(str::trim) {
|
|
||||||
if !matches!(
|
|
||||||
payment_method,
|
|
||||||
"none" | "bank_transfer" | "manual_invoice" | "enterprise_contract"
|
|
||||||
) {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Método de pago comercial inválido".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.reason.trim().len() < 3 {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Motivo de clasificación requerido".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(classification_missing_fields(
|
|
||||||
plan,
|
|
||||||
payload.payment_method.as_deref().map(str::trim),
|
|
||||||
payload.payment_terms_days,
|
|
||||||
payload.tg_limit,
|
|
||||||
payload.pbase_cop,
|
|
||||||
payload.discount_k,
|
|
||||||
payload.effective_from,
|
|
||||||
Some((
|
|
||||||
payload.enterprise_annual_cop,
|
|
||||||
payload.enterprise_monthly_cop,
|
|
||||||
payload.enterprise_override_reason.as_deref(),
|
|
||||||
)),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classification_persistence_intent(
|
|
||||||
payload: &CommercialClassificationPayload,
|
|
||||||
actor_user_id: Uuid,
|
|
||||||
) -> Result<ClassificationPersistenceIntent, AppError> {
|
|
||||||
let missing_fields = validate_classification_payload(payload)?;
|
|
||||||
let approved = missing_fields.is_empty();
|
|
||||||
let effective_from = payload
|
|
||||||
.effective_from
|
|
||||||
.unwrap_or_else(|| Utc::now().date_naive());
|
|
||||||
|
|
||||||
Ok(ClassificationPersistenceIntent {
|
|
||||||
missing_fields,
|
|
||||||
profile_status: if approved { "approved" } else { "in_review" },
|
|
||||||
request_status: if approved {
|
|
||||||
"approved"
|
|
||||||
} else {
|
|
||||||
"pending_classification"
|
|
||||||
},
|
|
||||||
approved_by: approved.then_some(actor_user_id),
|
|
||||||
approved_at_should_be_set: approved,
|
|
||||||
effective_from,
|
|
||||||
change_reason: payload.reason.trim().to_string(),
|
|
||||||
changed_fields: vec!["classification", "terms"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn classification_persistence_statements() -> ClassificationPersistenceStatements {
|
|
||||||
ClassificationPersistenceStatements {
|
|
||||||
profile_upsert: CLASSIFICATION_PROFILE_UPSERT_SQL,
|
|
||||||
version_insert: COMMERCIAL_PROFILE_VERSION_INSERT_SQL,
|
|
||||||
request_update: CLASSIFICATION_REQUEST_STATUS_UPDATE_SQL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn project_name_base(company: &str) -> String {
|
fn project_name_base(company: &str) -> String {
|
||||||
let sanitized: String = company
|
let sanitized: String = company
|
||||||
.chars()
|
.chars()
|
||||||
@@ -801,138 +393,6 @@ pub async fn get_access_request(
|
|||||||
Ok(Json(row))
|
Ok(Json(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/platform/commercial-profiles/:id
|
|
||||||
pub async fn get_commercial_profile(
|
|
||||||
_platform: PlatformClaims,
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<CommercialProfileRow>, AppError> {
|
|
||||||
Ok(Json(fetch_commercial_profile_by_id(&pool, id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/platform/access-requests/:id/classification
|
|
||||||
pub async fn classify_access_request(
|
|
||||||
platform: PlatformAdminClaims,
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<CommercialClassificationPayload>,
|
|
||||||
) -> Result<Json<CommercialProfileRow>, AppError> {
|
|
||||||
crate::auth::require_access_request_reviewer(&platform.0)?;
|
|
||||||
|
|
||||||
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
|
||||||
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
|
||||||
let persistence_intent = classification_persistence_intent(&payload, platform_user_id)?;
|
|
||||||
let classification_complete = persistence_intent.missing_fields.is_empty();
|
|
||||||
debug_assert_eq!(
|
|
||||||
classification_complete,
|
|
||||||
persistence_intent.approved_at_should_be_set
|
|
||||||
);
|
|
||||||
debug_assert_eq!(
|
|
||||||
classification_complete,
|
|
||||||
persistence_intent.request_status == "approved"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
let request_status: String = sqlx::query_scalar(
|
|
||||||
r#"SELECT status
|
|
||||||
FROM access_requests
|
|
||||||
WHERE id = $1
|
|
||||||
FOR UPDATE"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
|
||||||
|
|
||||||
if request_status == "rejected" {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"No se puede clasificar una solicitud rechazada".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let before_json: serde_json::Value = sqlx::query_scalar(
|
|
||||||
r#"SELECT COALESCE(to_jsonb(ccp), '{}'::jsonb)
|
|
||||||
FROM customer_commercial_profiles ccp
|
|
||||||
WHERE ccp.access_request_id = $1
|
|
||||||
ORDER BY ccp.updated_at DESC
|
|
||||||
LIMIT 1"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.unwrap_or_else(|| serde_json::json!({}));
|
|
||||||
|
|
||||||
let profile_id: Uuid = sqlx::query_scalar(CLASSIFICATION_PROFILE_UPSERT_SQL)
|
|
||||||
.bind(id)
|
|
||||||
.bind(persistence_intent.profile_status)
|
|
||||||
.bind(payload.plan.as_deref().map(str::trim))
|
|
||||||
.bind(payload.payment_method.as_deref().map(str::trim))
|
|
||||||
.bind(payload.payment_terms_days)
|
|
||||||
.bind(payload.credit_limit_cop)
|
|
||||||
.bind(payload.tg_limit)
|
|
||||||
.bind(payload.pbase_cop)
|
|
||||||
.bind(payload.discount_k)
|
|
||||||
.bind(Some(persistence_intent.effective_from))
|
|
||||||
.bind(classification_complete)
|
|
||||||
.bind(persistence_intent.approved_by)
|
|
||||||
.bind(payload.enterprise_annual_cop)
|
|
||||||
.bind(payload.enterprise_monthly_cop)
|
|
||||||
.bind(payload.enterprise_override_reason.as_deref().map(str::trim))
|
|
||||||
.bind(payload.notes.as_deref().map(str::trim))
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let after_json: serde_json::Value = sqlx::query_scalar(
|
|
||||||
r#"SELECT to_jsonb(ccp)
|
|
||||||
FROM customer_commercial_profiles ccp
|
|
||||||
WHERE ccp.id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let changed_fields: Vec<String> = persistence_intent
|
|
||||||
.changed_fields
|
|
||||||
.iter()
|
|
||||||
.map(|field| (*field).to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
sqlx::query(COMMERCIAL_PROFILE_VERSION_INSERT_SQL)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(platform_user_id)
|
|
||||||
.bind(&persistence_intent.change_reason)
|
|
||||||
.bind(changed_fields)
|
|
||||||
.bind(before_json.clone())
|
|
||||||
.bind(after_json.clone())
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(CLASSIFICATION_REQUEST_STATUS_UPDATE_SQL)
|
|
||||||
.bind(id)
|
|
||||||
.bind(classification_complete)
|
|
||||||
.bind(platform_user_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
crate::audit::log(
|
|
||||||
&pool,
|
|
||||||
crate::audit::AuditEntry::new("commercial_profile.classify")
|
|
||||||
.with_user(platform_user_id, &platform.0.email)
|
|
||||||
.with_resource(format!("commercial_profile:{profile_id}"))
|
|
||||||
.with_metadata(crate::audit::commercial_profile_change_metadata(
|
|
||||||
before_json,
|
|
||||||
after_json,
|
|
||||||
&persistence_intent.change_reason,
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(Json(fetch_commercial_profile_by_request(&pool, id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/platform/access-requests/:id/approve
|
/// POST /api/platform/access-requests/:id/approve
|
||||||
pub async fn approve_access_request(
|
pub async fn approve_access_request(
|
||||||
platform: PlatformAdminClaims,
|
platform: PlatformAdminClaims,
|
||||||
@@ -957,48 +417,12 @@ pub async fn approve_access_request(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
||||||
|
|
||||||
if !request_can_be_provisioned(&request.status) {
|
if !matches!(request.status.as_str(), "pending" | "in_review") {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"La solicitud requiere clasificación comercial aprobada antes de provisionarse"
|
"La solicitud no existe o ya fue cerrada".to_string(),
|
||||||
.to_string(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile_id: Uuid = sqlx::query_scalar(
|
|
||||||
r#"SELECT id
|
|
||||||
FROM customer_commercial_profiles
|
|
||||||
WHERE access_request_id = $1
|
|
||||||
AND status = 'approved'
|
|
||||||
AND plan IS NOT NULL
|
|
||||||
AND payment_method IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan = 'free'
|
|
||||||
OR (
|
|
||||||
payment_terms_days IS NOT NULL
|
|
||||||
AND tg_limit IS NOT NULL
|
|
||||||
AND effective_from IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan != 'enterprise'
|
|
||||||
OR (
|
|
||||||
enterprise_annual_cop IS NOT NULL
|
|
||||||
AND enterprise_monthly_cop IS NOT NULL
|
|
||||||
AND NULLIF(BTRIM(COALESCE(enterprise_override_reason, '')), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT 1"#,
|
|
||||||
)
|
|
||||||
.bind(request.id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::BadRequest(
|
|
||||||
"La solicitud requiere perfil comercial aprobado antes de provisionarse".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let base_project_name = project_name_base(&request.company);
|
let base_project_name = project_name_base(&request.company);
|
||||||
let project_name_exists: bool =
|
let project_name_exists: bool =
|
||||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
|
||||||
@@ -1135,18 +559,6 @@ pub async fn approve_access_request(
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"UPDATE customer_commercial_profiles
|
|
||||||
SET project_id = $2,
|
|
||||||
status = 'active',
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
let email = if let Some(invitation) = &invitation {
|
let email = if let Some(invitation) = &invitation {
|
||||||
@@ -1796,9 +1208,8 @@ pub async fn mark_access_request_in_review(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ACCESS_REQUEST_SELECT, ApprovalRequestRow, ResendDeliveryKind,
|
ACCESS_REQUEST_SELECT, ApprovalRequestRow, ResendDeliveryKind,
|
||||||
approval_provisioning_audit_actions, classification_missing_fields,
|
approval_provisioning_audit_actions, invitation_email_delivery_update, project_defaults,
|
||||||
invitation_email_delivery_update, project_defaults, provisioning_audit_metadata,
|
provisioning_audit_metadata, resend_delivery_kind, should_revoke_invitation_on_resend,
|
||||||
request_can_be_provisioned, resend_delivery_kind, should_revoke_invitation_on_resend,
|
|
||||||
};
|
};
|
||||||
use crate::auth::{PlatformAdminClaims, PlatformClaims};
|
use crate::auth::{PlatformAdminClaims, PlatformClaims};
|
||||||
use axum::{Json, extract::Path, extract::State};
|
use axum::{Json, extract::Path, extract::State};
|
||||||
@@ -2008,174 +1419,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(decision, ResendDeliveryKind::ActivationInvitation);
|
assert_eq!(decision, ResendDeliveryKind::ActivationInvitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_classification_requires_terms_limits_and_effective_date_for_operative() {
|
|
||||||
let missing = classification_missing_fields(None, None, None, None, None, None, None, None);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
missing,
|
|
||||||
[
|
|
||||||
"plan",
|
|
||||||
"payment_method",
|
|
||||||
"payment_terms_days",
|
|
||||||
"tg_limit",
|
|
||||||
"effective_from"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
let complete = classification_missing_fields(
|
|
||||||
Some("operative"),
|
|
||||||
Some("bank_transfer"),
|
|
||||||
Some(30),
|
|
||||||
Some(10.0),
|
|
||||||
Some(110_000),
|
|
||||||
Some(0.1),
|
|
||||||
Some(chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap()),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(complete.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_free_classification_is_complete_with_explicit_no_charge_defaults() {
|
|
||||||
let missing = classification_missing_fields(
|
|
||||||
Some("free"),
|
|
||||||
Some("none"),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(110_000),
|
|
||||||
Some(0.1),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_free_classification_persistence_intent_approves_without_billing_terms() {
|
|
||||||
let actor_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap();
|
|
||||||
let payload = super::CommercialClassificationPayload {
|
|
||||||
plan: Some("free".to_string()),
|
|
||||||
payment_method: Some("none".to_string()),
|
|
||||||
payment_terms_days: None,
|
|
||||||
credit_limit_cop: None,
|
|
||||||
tg_limit: None,
|
|
||||||
pbase_cop: Some(110_000),
|
|
||||||
discount_k: Some(0.1),
|
|
||||||
effective_from: None,
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
notes: Some("Free non-collecting access".to_string()),
|
|
||||||
reason: "Clasificacion Free".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let intent = super::classification_persistence_intent(&payload, actor_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(intent.profile_status, "approved");
|
|
||||||
assert_eq!(intent.request_status, "approved");
|
|
||||||
assert_eq!(intent.approved_by, Some(actor_id));
|
|
||||||
assert!(intent.approved_at_should_be_set);
|
|
||||||
assert!(intent.missing_fields.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enterprise_classification_requires_override_values_and_reason() {
|
|
||||||
let missing = classification_missing_fields(
|
|
||||||
Some("enterprise"),
|
|
||||||
Some("enterprise_contract"),
|
|
||||||
Some(45),
|
|
||||||
Some(100.0),
|
|
||||||
Some(110_000),
|
|
||||||
Some(0.1),
|
|
||||||
Some(chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap()),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
missing,
|
|
||||||
[
|
|
||||||
"enterprise_annual_cop",
|
|
||||||
"enterprise_monthly_cop",
|
|
||||||
"enterprise_override_reason"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn approval_is_blocked_until_profile_is_commercially_approved() {
|
|
||||||
assert!(!request_can_be_provisioned("pending"));
|
|
||||||
assert!(!request_can_be_provisioned("in_review"));
|
|
||||||
assert!(!request_can_be_provisioned("pending_classification"));
|
|
||||||
assert!(request_can_be_provisioned("approved"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_classification_persistence_intent_records_effective_date_approver_and_version_shape()
|
|
||||||
{
|
|
||||||
let actor_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap();
|
|
||||||
let effective_from = chrono::NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
|
||||||
let payload = super::CommercialClassificationPayload {
|
|
||||||
plan: Some("operative".to_string()),
|
|
||||||
payment_method: Some("manual_invoice".to_string()),
|
|
||||||
payment_terms_days: Some(30),
|
|
||||||
credit_limit_cop: Some(25_000_000),
|
|
||||||
tg_limit: Some(75.0),
|
|
||||||
pbase_cop: Some(110_000),
|
|
||||||
discount_k: Some(0.1),
|
|
||||||
effective_from: Some(effective_from),
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
notes: Some("Approved commercial terms".to_string()),
|
|
||||||
reason: "Terms approved for July rollout".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let intent = super::classification_persistence_intent(&payload, actor_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(intent.profile_status, "approved");
|
|
||||||
assert_eq!(intent.request_status, "approved");
|
|
||||||
assert_eq!(intent.approved_by, Some(actor_id));
|
|
||||||
assert!(intent.approved_at_should_be_set);
|
|
||||||
assert_eq!(intent.effective_from, effective_from);
|
|
||||||
assert_eq!(intent.change_reason, "Terms approved for July rollout");
|
|
||||||
assert_eq!(intent.changed_fields, ["classification", "terms"]);
|
|
||||||
assert!(intent.missing_fields.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_classification_sql_intent_persists_profile_upsert_version_and_access_request_status()
|
|
||||||
{
|
|
||||||
let statements = super::classification_persistence_statements();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
statements
|
|
||||||
.profile_upsert
|
|
||||||
.contains("INSERT INTO customer_commercial_profiles")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
statements
|
|
||||||
.profile_upsert
|
|
||||||
.contains("ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL")
|
|
||||||
);
|
|
||||||
assert!(statements.profile_upsert.contains("effective_from"));
|
|
||||||
assert!(statements.profile_upsert.contains("approved_by"));
|
|
||||||
assert!(statements.profile_upsert.contains("approved_at"));
|
|
||||||
assert!(
|
|
||||||
statements
|
|
||||||
.version_insert
|
|
||||||
.contains("INSERT INTO customer_commercial_profile_versions")
|
|
||||||
);
|
|
||||||
assert!(statements.version_insert.contains("actor_user_id"));
|
|
||||||
assert!(statements.version_insert.contains("change_reason"));
|
|
||||||
assert!(statements.version_insert.contains("changed_fields"));
|
|
||||||
assert!(statements.version_insert.contains("before_json"));
|
|
||||||
assert!(statements.version_insert.contains("after_json"));
|
|
||||||
assert!(statements.request_update.contains("pending_classification"));
|
|
||||||
assert!(statements.request_update.contains("reviewed_by"));
|
|
||||||
assert!(statements.request_update.contains("reviewed_at"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,14 +105,6 @@ struct AccessRequestResponse {
|
|||||||
message: &'static str,
|
message: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUEST_ACCESS_DRAFT_PROFILE_SQL: &str = r#"INSERT INTO customer_commercial_profiles (access_request_id, status, plan, payment_method, effective_from, notes)
|
|
||||||
VALUES ($1, 'draft', 'free', 'none', CURRENT_DATE, 'Created from public access request; pending commercial classification.')
|
|
||||||
ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL DO NOTHING"#;
|
|
||||||
|
|
||||||
const REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL: &str = r#"UPDATE access_requests
|
|
||||||
SET status = 'pending_classification', updated_at = NOW()
|
|
||||||
WHERE id = $1 AND status = 'pending'"#;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct UserRow {
|
struct UserRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
@@ -460,7 +452,7 @@ pub async fn request_access(
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM access_requests
|
FROM access_requests
|
||||||
WHERE lower(email) = $1
|
WHERE lower(email) = $1
|
||||||
AND status IN ('pending', 'pending_classification', 'in_review', 'approved')
|
AND status IN ('pending', 'in_review', 'approved')
|
||||||
)"#,
|
)"#,
|
||||||
)
|
)
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
@@ -475,7 +467,7 @@ pub async fn request_access(
|
|||||||
return Ok(duplicate_access_request_response());
|
return Ok(duplicate_access_request_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id_result: Result<uuid::Uuid, sqlx::Error> = sqlx::query_scalar(
|
let request_id = sqlx::query_scalar(
|
||||||
r#"INSERT INTO access_requests
|
r#"INSERT INTO access_requests
|
||||||
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
|
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
@@ -500,7 +492,7 @@ pub async fn request_access(
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let request_id = match request_id_result {
|
let request_id = match request_id {
|
||||||
Ok(request_id) => request_id,
|
Ok(request_id) => request_id,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if is_active_access_request_unique_violation(&error) {
|
if is_active_access_request_unique_violation(&error) {
|
||||||
@@ -525,29 +517,42 @@ pub async fn request_access(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
sqlx::query(REQUEST_ACCESS_DRAFT_PROFILE_SQL)
|
let approved_request = if let Ok(row) =
|
||||||
|
crate::handlers::access_requests::auto_approve_access_request(&pool, request_id, &headers)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
row
|
||||||
|
} else {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"UPDATE access_requests
|
||||||
|
SET status = 'rejected', rejection_reason = 'auto_approval_failed', updated_at = NOW()
|
||||||
|
WHERE id = $1 AND status = 'pending'"#,
|
||||||
|
)
|
||||||
.bind(request_id)
|
.bind(request_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await;
|
||||||
.map_err(|error| {
|
tracing::error!(request_id = %request_id, "Access request auto-approval failed");
|
||||||
tracing::error!(request_id = %request_id, error = %error, "Commercial profile creation failed");
|
return Err(AuthError::Internal(anyhow::anyhow!(
|
||||||
AuthError::Internal(anyhow::anyhow!("Error al registrar perfil comercial"))
|
"Error al aprobar la prueba gratuita"
|
||||||
})?;
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query(REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL)
|
if approved_request.invitation_email_status.as_deref() == Some("failed") {
|
||||||
.bind(request_id)
|
return Ok((
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
tracing::error!(request_id = %request_id, error = %error, "Access request classification status update failed");
|
|
||||||
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::ACCEPTED,
|
||||||
Json(AccessRequestResponse {
|
Json(AccessRequestResponse {
|
||||||
status: "pending_classification",
|
status: "approved_email_failed",
|
||||||
message: "Recibimos tu solicitud. El equipo comercial la revisará antes de activar el acceso.",
|
message: "Tu prueba gratuita fue aprobada, pero no pudimos enviar el correo de activación. Escríbenos a soporte@omnioil.app.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(AccessRequestResponse {
|
||||||
|
status: "approved",
|
||||||
|
message: "Tu prueba gratuita fue aprobada. Revisa tu correo para activar la cuenta.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
@@ -976,34 +981,6 @@ pub async fn get_setup_status(State(pool): State<DbPool>) -> Result<Json<SetupSt
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{REQUEST_ACCESS_DRAFT_PROFILE_SQL, REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_request_access_persists_draft_profile_before_pending_classification_response() {
|
|
||||||
assert!(
|
|
||||||
REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("INSERT INTO customer_commercial_profiles")
|
|
||||||
);
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("access_request_id"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'draft'"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'free'"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'none'"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("CURRENT_DATE"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains(
|
|
||||||
"ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL DO NOTHING"
|
|
||||||
));
|
|
||||||
assert!(
|
|
||||||
REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL
|
|
||||||
.contains("SET status = 'pending_classification'")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL
|
|
||||||
.contains("WHERE id = $1 AND status = 'pending'")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetupRegisterPayload {
|
pub struct SetupRegisterPayload {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ use std::collections::HashMap;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{Claims, PlatformAdminClaims};
|
use crate::auth::{Claims, PlatformAdminClaims};
|
||||||
use crate::billing_rules::{self, OperativeBillingInput};
|
|
||||||
|
|
||||||
const LEGACY_DEFAULT_PRICE_PER_TAG_COP: i64 = 90_000;
|
|
||||||
const OFFICIAL_PRICE_PER_TAG_COP: i64 = 110_000;
|
|
||||||
|
|
||||||
// ─── Error helper ───────────────────────────────────────────────────────────
|
// ─── Error helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -66,8 +62,6 @@ pub struct BillingUsage {
|
|||||||
pub project_name: String,
|
pub project_name: String,
|
||||||
pub tier: String,
|
pub tier: String,
|
||||||
pub subscription_status: String,
|
pub subscription_status: String,
|
||||||
pub hf_variables: i64,
|
|
||||||
pub lf_variables: i64,
|
|
||||||
pub active_tags: i64,
|
pub active_tags: i64,
|
||||||
pub tag_limit: i32,
|
pub tag_limit: i32,
|
||||||
pub price_per_tag: i64,
|
pub price_per_tag: i64,
|
||||||
@@ -79,47 +73,6 @@ pub struct BillingUsage {
|
|||||||
pub period_start: DateTime<Utc>,
|
pub period_start: DateTime<Utc>,
|
||||||
pub period_end: DateTime<Utc>,
|
pub period_end: DateTime<Utc>,
|
||||||
pub last_snapshot: Option<NaiveDate>,
|
pub last_snapshot: Option<NaiveDate>,
|
||||||
pub commercial_profile: Option<CommercialProfileBillingSummary>,
|
|
||||||
pub formula: Option<OperativeFormulaSummary>,
|
|
||||||
pub calculation_lines: Vec<BillingCalculationLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct CommercialProfileBillingSummary {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
pub plan: String,
|
|
||||||
pub payment_method: Option<String>,
|
|
||||||
pub payment_terms_days: Option<i32>,
|
|
||||||
pub credit_limit_cop: Option<i64>,
|
|
||||||
pub tg_limit: Option<f64>,
|
|
||||||
pub effective_from: NaiveDate,
|
|
||||||
pub profile_complete: bool,
|
|
||||||
pub missing_fields: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperativeFormulaSummary {
|
|
||||||
pub hf_tags: u32,
|
|
||||||
pub lf_tags: u32,
|
|
||||||
pub equivalent_tg: f64,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub k: f64,
|
|
||||||
pub annual_cop: i64,
|
|
||||||
pub monthly_cop: i64,
|
|
||||||
pub currency: String,
|
|
||||||
pub rounding_policy: String,
|
|
||||||
pub effective_from: NaiveDate,
|
|
||||||
pub effective_hf_price_cop: i64,
|
|
||||||
pub effective_lf_price_cop: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct BillingCalculationLine {
|
|
||||||
pub kind: String,
|
|
||||||
pub label: String,
|
|
||||||
pub amount_cop: i64,
|
|
||||||
pub source: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -150,50 +103,6 @@ struct ProjectLookup {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct BillingVariableCounts {
|
|
||||||
hf_variables: i64,
|
|
||||||
lf_variables: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CurrentBillingCalculation {
|
|
||||||
active_tags: i64,
|
|
||||||
price_per_tag: i64,
|
|
||||||
subtotal_annual: i64,
|
|
||||||
minimum_applied: bool,
|
|
||||||
total_annual_cop: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
|
||||||
struct CommercialProfileForBilling {
|
|
||||||
id: Uuid,
|
|
||||||
status: String,
|
|
||||||
plan: String,
|
|
||||||
payment_method: Option<String>,
|
|
||||||
payment_terms_days: Option<i32>,
|
|
||||||
credit_limit_cop: Option<i64>,
|
|
||||||
tg_limit: Option<f64>,
|
|
||||||
pbase_cop: i64,
|
|
||||||
discount_k: f64,
|
|
||||||
currency: String,
|
|
||||||
effective_from: NaiveDate,
|
|
||||||
enterprise_annual_cop: Option<i64>,
|
|
||||||
enterprise_monthly_cop: Option<i64>,
|
|
||||||
enterprise_override_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProfileBillingResponse {
|
|
||||||
active_tags: i64,
|
|
||||||
price_per_tag: i64,
|
|
||||||
subtotal_annual: i64,
|
|
||||||
minimum_applied: bool,
|
|
||||||
total_annual_cop: i64,
|
|
||||||
total_monthly_cop: i64,
|
|
||||||
commercial_profile: CommercialProfileBillingSummary,
|
|
||||||
formula: Option<OperativeFormulaSummary>,
|
|
||||||
calculation_lines: Vec<BillingCalculationLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateSubscriptionRequest {
|
pub struct UpdateSubscriptionRequest {
|
||||||
pub tier: Option<String>,
|
pub tier: Option<String>,
|
||||||
@@ -228,7 +137,7 @@ async fn get_or_create_subscription(
|
|||||||
// Auto-create starter subscription
|
// Auto-create starter subscription
|
||||||
sqlx::query_as::<_, Subscription>(
|
sqlx::query_as::<_, Subscription>(
|
||||||
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, price_per_tag, min_annual_fee, status)
|
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, price_per_tag, min_annual_fee, status)
|
||||||
VALUES ($1, 'starter', 50, 110000, 4000000, 'active')
|
VALUES ($1, 'starter', 50, 90000, 4000000, 'active')
|
||||||
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
|
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING *"#,
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
@@ -252,380 +161,6 @@ fn calculate_billing(
|
|||||||
(subtotal, minimum_applied, total)
|
(subtotal, minimum_applied, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn effective_price_per_tag(stored_price_per_tag: i64) -> i64 {
|
|
||||||
if stored_price_per_tag == LEGACY_DEFAULT_PRICE_PER_TAG_COP {
|
|
||||||
OFFICIAL_PRICE_PER_TAG_COP
|
|
||||||
} else {
|
|
||||||
stored_price_per_tag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_current_billing(
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
stored_price_per_tag: i64,
|
|
||||||
min_annual_fee: i64,
|
|
||||||
) -> CurrentBillingCalculation {
|
|
||||||
let active_tags = counts.hf_variables + counts.lf_variables;
|
|
||||||
let price_per_tag = effective_price_per_tag(stored_price_per_tag);
|
|
||||||
let (subtotal_annual, minimum_applied, total_annual_cop) =
|
|
||||||
calculate_billing(active_tags, price_per_tag, min_annual_fee);
|
|
||||||
|
|
||||||
CurrentBillingCalculation {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag,
|
|
||||||
subtotal_annual,
|
|
||||||
minimum_applied,
|
|
||||||
total_annual_cop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_billing_usage_response(
|
|
||||||
project_id: Uuid,
|
|
||||||
project_name: String,
|
|
||||||
sub: &Subscription,
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
billing: CurrentBillingCalculation,
|
|
||||||
last_snapshot: Option<NaiveDate>,
|
|
||||||
) -> BillingUsage {
|
|
||||||
BillingUsage {
|
|
||||||
project_id,
|
|
||||||
project_name,
|
|
||||||
tier: sub.tier.clone(),
|
|
||||||
subscription_status: sub.status.clone(),
|
|
||||||
hf_variables: counts.hf_variables,
|
|
||||||
lf_variables: counts.lf_variables,
|
|
||||||
active_tags: billing.active_tags,
|
|
||||||
tag_limit: sub.tag_limit,
|
|
||||||
price_per_tag: billing.price_per_tag,
|
|
||||||
min_annual_fee: sub.min_annual_fee,
|
|
||||||
subtotal_annual: billing.subtotal_annual,
|
|
||||||
minimum_applied: billing.minimum_applied,
|
|
||||||
total_annual_cop: billing.total_annual_cop,
|
|
||||||
total_monthly_cop: billing.total_annual_cop / 12,
|
|
||||||
period_start: sub.current_period_start,
|
|
||||||
period_end: sub.current_period_end,
|
|
||||||
last_snapshot,
|
|
||||||
commercial_profile: None,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "legacy_subscription".to_string(),
|
|
||||||
label: "Legacy subscription tag billing".to_string(),
|
|
||||||
amount_cop: billing.total_annual_cop,
|
|
||||||
source: Some("subscriptions".to_string()),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn profile_missing_fields(profile: &CommercialProfileForBilling) -> Vec<String> {
|
|
||||||
let mut missing = Vec::new();
|
|
||||||
|
|
||||||
if profile.payment_method.is_none() {
|
|
||||||
missing.push("payment_method".to_string());
|
|
||||||
}
|
|
||||||
if profile.plan != "free" {
|
|
||||||
if profile.payment_terms_days.is_none() {
|
|
||||||
missing.push("payment_terms_days".to_string());
|
|
||||||
}
|
|
||||||
if profile.tg_limit.is_none() {
|
|
||||||
missing.push("tg_limit".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.plan == "enterprise" {
|
|
||||||
if profile.enterprise_annual_cop.is_none() {
|
|
||||||
missing.push("enterprise_annual_cop".to_string());
|
|
||||||
}
|
|
||||||
if profile.enterprise_monthly_cop.is_none() {
|
|
||||||
missing.push("enterprise_monthly_cop".to_string());
|
|
||||||
}
|
|
||||||
if profile
|
|
||||||
.enterprise_override_reason
|
|
||||||
.as_deref()
|
|
||||||
.is_none_or(|reason| reason.trim().is_empty())
|
|
||||||
{
|
|
||||||
missing.push("enterprise_override_reason".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
missing
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commercial_profile_summary(
|
|
||||||
profile: &CommercialProfileForBilling,
|
|
||||||
missing_fields: Vec<String>,
|
|
||||||
) -> CommercialProfileBillingSummary {
|
|
||||||
CommercialProfileBillingSummary {
|
|
||||||
id: profile.id,
|
|
||||||
status: profile.status.clone(),
|
|
||||||
plan: profile.plan.clone(),
|
|
||||||
payment_method: profile.payment_method.clone(),
|
|
||||||
payment_terms_days: profile.payment_terms_days,
|
|
||||||
credit_limit_cop: profile.credit_limit_cop,
|
|
||||||
tg_limit: profile.tg_limit,
|
|
||||||
effective_from: profile.effective_from,
|
|
||||||
profile_complete: missing_fields.is_empty()
|
|
||||||
&& matches!(profile.status.as_str(), "approved" | "active"),
|
|
||||||
missing_fields,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_profile_billing_response(
|
|
||||||
profile: &CommercialProfileForBilling,
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
) -> Result<ProfileBillingResponse, billing_rules::BillingRulesError> {
|
|
||||||
let missing_fields = profile_missing_fields(profile);
|
|
||||||
let commercial_profile = commercial_profile_summary(profile, missing_fields);
|
|
||||||
let active_tags = counts.hf_variables + counts.lf_variables;
|
|
||||||
|
|
||||||
if !commercial_profile.profile_complete {
|
|
||||||
return Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
commercial_profile,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "incomplete_profile".to_string(),
|
|
||||||
label: "Incomplete commercial profile is non-billable".to_string(),
|
|
||||||
amount_cop: 0,
|
|
||||||
source: Some("customer_commercial_profiles".to_string()),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match profile.plan.as_str() {
|
|
||||||
"free" => Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
commercial_profile,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "free_non_collecting".to_string(),
|
|
||||||
label: "Free plan does not collect payment".to_string(),
|
|
||||||
amount_cop: 0,
|
|
||||||
source: Some("customer_commercial_profiles".to_string()),
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
"enterprise" => {
|
|
||||||
let annual = profile.enterprise_annual_cop.unwrap_or(0);
|
|
||||||
let monthly = profile.enterprise_monthly_cop.unwrap_or(0);
|
|
||||||
Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: 0,
|
|
||||||
subtotal_annual: annual,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: annual,
|
|
||||||
total_monthly_cop: monthly,
|
|
||||||
commercial_profile,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "enterprise_override".to_string(),
|
|
||||||
label: "Enterprise override pricing".to_string(),
|
|
||||||
amount_cop: annual,
|
|
||||||
source: profile.enterprise_override_reason.clone(),
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let breakdown = billing_rules::calculate_operative_billing(OperativeBillingInput {
|
|
||||||
hf_tags: counts.hf_variables.max(0) as u32,
|
|
||||||
lf_tags: counts.lf_variables.max(0) as u32,
|
|
||||||
pbase_cop: profile.pbase_cop,
|
|
||||||
k: profile.discount_k,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: breakdown.effective_hf_price_cop,
|
|
||||||
subtotal_annual: breakdown.annual_cop,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: breakdown.annual_cop,
|
|
||||||
total_monthly_cop: breakdown.monthly_cop,
|
|
||||||
commercial_profile,
|
|
||||||
formula: Some(OperativeFormulaSummary {
|
|
||||||
hf_tags: breakdown.hf_tags,
|
|
||||||
lf_tags: breakdown.lf_tags,
|
|
||||||
equivalent_tg: breakdown.equivalent_tg,
|
|
||||||
pbase_cop: breakdown.pbase_cop,
|
|
||||||
k: breakdown.k,
|
|
||||||
annual_cop: breakdown.annual_cop,
|
|
||||||
monthly_cop: breakdown.monthly_cop,
|
|
||||||
currency: profile.currency.clone(),
|
|
||||||
rounding_policy: breakdown.rounding_policy.to_string(),
|
|
||||||
effective_from: profile.effective_from,
|
|
||||||
effective_hf_price_cop: breakdown.effective_hf_price_cop,
|
|
||||||
effective_lf_price_cop: breakdown.effective_lf_price_cop,
|
|
||||||
}),
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "operative_formula".to_string(),
|
|
||||||
label: "V = HF + (LF * 0.4); annual = V * Pbase * V^-k".to_string(),
|
|
||||||
amount_cop: breakdown.annual_cop,
|
|
||||||
source: Some("billing_rules".to_string()),
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_profile_billing_usage_response(
|
|
||||||
project_id: Uuid,
|
|
||||||
project_name: String,
|
|
||||||
sub: &Subscription,
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
billing: ProfileBillingResponse,
|
|
||||||
last_snapshot: Option<NaiveDate>,
|
|
||||||
) -> BillingUsage {
|
|
||||||
BillingUsage {
|
|
||||||
project_id,
|
|
||||||
project_name,
|
|
||||||
tier: billing.commercial_profile.plan.clone(),
|
|
||||||
subscription_status: sub.status.clone(),
|
|
||||||
hf_variables: counts.hf_variables,
|
|
||||||
lf_variables: counts.lf_variables,
|
|
||||||
active_tags: billing.active_tags,
|
|
||||||
tag_limit: sub.tag_limit,
|
|
||||||
price_per_tag: billing.price_per_tag,
|
|
||||||
min_annual_fee: 0,
|
|
||||||
subtotal_annual: billing.subtotal_annual,
|
|
||||||
minimum_applied: billing.minimum_applied,
|
|
||||||
total_annual_cop: billing.total_annual_cop,
|
|
||||||
total_monthly_cop: billing.total_monthly_cop,
|
|
||||||
period_start: sub.current_period_start,
|
|
||||||
period_end: sub.current_period_end,
|
|
||||||
last_snapshot,
|
|
||||||
commercial_profile: Some(billing.commercial_profile),
|
|
||||||
formula: billing.formula,
|
|
||||||
calculation_lines: billing.calculation_lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hf_variables_count_sql() -> &'static str {
|
|
||||||
r#"SELECT COUNT(*)
|
|
||||||
FROM edge_variables ev
|
|
||||||
JOIN edge_devices ed ON ev.device_id = ed.id
|
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
|
||||||
WHERE ea.project_id = $1
|
|
||||||
AND ev.is_enabled = true
|
|
||||||
AND ed.is_enabled = true
|
|
||||||
AND ea.is_active = true"#
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lf_variables_count_sql() -> &'static str {
|
|
||||||
r#"SELECT COUNT(DISTINCT lower(field_name))
|
|
||||||
FROM telemetry_raw tr
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT field_name, field_value
|
|
||||||
FROM jsonb_each(tr.data) AS top_level(field_name, field_value)
|
|
||||||
UNION ALL
|
|
||||||
SELECT field_name, field_value
|
|
||||||
-- Expands values telemetry shape: jsonb_each(tr.data -> 'values')
|
|
||||||
FROM jsonb_each(
|
|
||||||
CASE
|
|
||||||
WHEN jsonb_typeof(tr.data -> 'values') = 'object' THEN tr.data -> 'values'
|
|
||||||
ELSE '{}'::jsonb
|
|
||||||
END
|
|
||||||
) AS values_fields(field_name, field_value)
|
|
||||||
UNION ALL
|
|
||||||
SELECT field_name, field_value
|
|
||||||
-- Expands manual capture shape: jsonb_each(tr.data -> 'manual_fields')
|
|
||||||
FROM jsonb_each(
|
|
||||||
CASE
|
|
||||||
WHEN jsonb_typeof(tr.data -> 'manual_fields') = 'object' THEN tr.data -> 'manual_fields'
|
|
||||||
ELSE '{}'::jsonb
|
|
||||||
END
|
|
||||||
) AS manual_fields(field_name, field_value)
|
|
||||||
) fields
|
|
||||||
WHERE tr.project_id = $1
|
|
||||||
AND tr.source IN ('PWA', 'MANUAL')
|
|
||||||
AND jsonb_typeof(field_value) = 'number'
|
|
||||||
AND lower(field_name) NOT IN (
|
|
||||||
'project_id', 'device_name', 'timestamp', 'asset_id', 'asset',
|
|
||||||
'last_updated', 'telemetry', 'time', 'data', 'id', 'source',
|
|
||||||
'frequency_class', 'created_at', 'updated_at', 'tipo',
|
|
||||||
'observaciones', 'values', 'variables', 'manual_fields', 'client_id'
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
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
|
|
||||||
WHERE ea.project_id = tr.project_id
|
|
||||||
AND ev.is_enabled = true
|
|
||||||
AND ed.is_enabled = true
|
|
||||||
AND ea.is_active = true
|
|
||||||
AND lower(ev.alias) = lower(field_name)
|
|
||||||
)"#
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_hf_variables(pool: &PgPool, project_id: Uuid) -> Result<i64, sqlx::Error> {
|
|
||||||
sqlx::query_scalar::<_, i64>(hf_variables_count_sql())
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_lf_variables(pool: &PgPool, project_id: Uuid) -> Result<i64, sqlx::Error> {
|
|
||||||
sqlx::query_scalar::<_, i64>(lf_variables_count_sql())
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_billing_variables(
|
|
||||||
pool: &PgPool,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<BillingVariableCounts, sqlx::Error> {
|
|
||||||
let hf_variables = count_hf_variables(pool, project_id).await?;
|
|
||||||
let lf_variables = count_lf_variables(pool, project_id).await?;
|
|
||||||
|
|
||||||
Ok(BillingVariableCounts {
|
|
||||||
hf_variables,
|
|
||||||
lf_variables,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_commercial_profile_for_billing(
|
|
||||||
pool: &PgPool,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<Option<CommercialProfileForBilling>, sqlx::Error> {
|
|
||||||
sqlx::query_as::<_, CommercialProfileForBilling>(
|
|
||||||
r#"SELECT id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
credit_limit_cop,
|
|
||||||
tg_limit::float8 AS tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k::float8 AS discount_k,
|
|
||||||
currency,
|
|
||||||
effective_from,
|
|
||||||
enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason
|
|
||||||
FROM customer_commercial_profiles
|
|
||||||
WHERE project_id = $1
|
|
||||||
AND status IN ('approved', 'active', 'in_review', 'draft', 'suspended')
|
|
||||||
ORDER BY CASE status
|
|
||||||
WHEN 'active' THEN 0
|
|
||||||
WHEN 'approved' THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END,
|
|
||||||
effective_from DESC,
|
|
||||||
updated_at DESC
|
|
||||||
LIMIT 1"#,
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/billing/usage?project_id=UUID
|
/// GET /api/billing/usage?project_id=UUID
|
||||||
@@ -658,11 +193,21 @@ pub async fn get_billing_usage(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Count current billable variables from real persisted HF/LF sources.
|
// Count active tags
|
||||||
let counts = match count_billing_variables(&pool, project_id).await {
|
let tag_count: i64 = match sqlx::query_scalar(
|
||||||
Ok(counts) => counts,
|
r#"SELECT COUNT(*)
|
||||||
|
FROM edge_variables ev
|
||||||
|
JOIN edge_devices ed ON ev.device_id = ed.id
|
||||||
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
|
WHERE ea.project_id = $1 AND ev.is_enabled = true"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("DB error counting billing variables: {}", e);
|
tracing::error!("DB error counting tags: {}", e);
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -685,47 +230,26 @@ pub async fn get_billing_usage(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
|
|
||||||
let profile = match fetch_commercial_profile_for_billing(&pool, project_id).await {
|
let (subtotal, minimum_applied, total) =
|
||||||
Ok(profile) => profile,
|
calculate_billing(tag_count, sub.price_per_tag, sub.min_annual_fee);
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("DB error fetching commercial profile: {}", e);
|
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(profile) = profile {
|
Json(BillingUsage {
|
||||||
let billing = match build_profile_billing_response(&profile, counts) {
|
|
||||||
Ok(billing) => billing,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(?error, "Commercial billing calculation failed");
|
|
||||||
return err(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Billing calculation error",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Json(build_profile_billing_usage_response(
|
|
||||||
project_id,
|
project_id,
|
||||||
project.name,
|
project_name: project.name,
|
||||||
&sub,
|
tier: sub.tier,
|
||||||
counts,
|
subscription_status: sub.status,
|
||||||
billing,
|
active_tags: tag_count,
|
||||||
|
tag_limit: sub.tag_limit,
|
||||||
|
price_per_tag: sub.price_per_tag,
|
||||||
|
min_annual_fee: sub.min_annual_fee,
|
||||||
|
subtotal_annual: subtotal,
|
||||||
|
minimum_applied,
|
||||||
|
total_annual_cop: total,
|
||||||
|
total_monthly_cop: total / 12,
|
||||||
|
period_start: sub.current_period_start,
|
||||||
|
period_end: sub.current_period_end,
|
||||||
last_snapshot,
|
last_snapshot,
|
||||||
))
|
})
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let billing = calculate_current_billing(counts, sub.price_per_tag, sub.min_annual_fee);
|
|
||||||
|
|
||||||
Json(build_billing_usage_response(
|
|
||||||
project_id,
|
|
||||||
project.name,
|
|
||||||
&sub,
|
|
||||||
counts,
|
|
||||||
billing,
|
|
||||||
last_snapshot,
|
|
||||||
))
|
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,289 +516,3 @@ pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> {
|
|||||||
tracing::info!("📊 Usage snapshots taken for {} projects", count);
|
tracing::info!("📊 Usage snapshots taken for {} projects", count);
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn current_billing_uses_split_counts_and_official_legacy_price() {
|
|
||||||
let counts = BillingVariableCounts {
|
|
||||||
hf_variables: 7,
|
|
||||||
lf_variables: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
let billing = calculate_current_billing(counts, 90_000, 4_000_000);
|
|
||||||
|
|
||||||
assert_eq!(OFFICIAL_PRICE_PER_TAG_COP, 110_000);
|
|
||||||
assert_eq!(billing.active_tags, 12);
|
|
||||||
assert_eq!(billing.price_per_tag, 110_000);
|
|
||||||
assert_eq!(billing.subtotal_annual, 1_320_000);
|
|
||||||
assert!(billing.minimum_applied);
|
|
||||||
assert_eq!(billing.total_annual_cop, 4_000_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn current_billing_preserves_custom_non_legacy_price() {
|
|
||||||
let counts = BillingVariableCounts {
|
|
||||||
hf_variables: 20,
|
|
||||||
lf_variables: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
let billing = calculate_current_billing(counts, 125_000, 1_000_000);
|
|
||||||
|
|
||||||
assert_eq!(billing.active_tags, 23);
|
|
||||||
assert_eq!(billing.price_per_tag, 125_000);
|
|
||||||
assert_eq!(billing.subtotal_annual, 2_875_000);
|
|
||||||
assert!(!billing.minimum_applied);
|
|
||||||
assert_eq!(billing.total_annual_cop, 2_875_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn billing_usage_response_serializes_hf_lf_and_active_tag_total() {
|
|
||||||
let project_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
|
|
||||||
let now = Utc::now();
|
|
||||||
let subscription = Subscription {
|
|
||||||
id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
|
|
||||||
project_id,
|
|
||||||
tier: "starter".to_string(),
|
|
||||||
tag_limit: 50,
|
|
||||||
price_per_tag: 90_000,
|
|
||||||
min_annual_fee: 4_000_000,
|
|
||||||
status: "active".to_string(),
|
|
||||||
trial_ends_at: None,
|
|
||||||
current_period_start: now,
|
|
||||||
current_period_end: now,
|
|
||||||
notes: None,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
let counts = BillingVariableCounts {
|
|
||||||
hf_variables: 8,
|
|
||||||
lf_variables: 4,
|
|
||||||
};
|
|
||||||
let billing = calculate_current_billing(
|
|
||||||
counts,
|
|
||||||
subscription.price_per_tag,
|
|
||||||
subscription.min_annual_fee,
|
|
||||||
);
|
|
||||||
|
|
||||||
let usage = build_billing_usage_response(
|
|
||||||
project_id,
|
|
||||||
"Demo Project".to_string(),
|
|
||||||
&subscription,
|
|
||||||
counts,
|
|
||||||
billing,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let serialized = serde_json::to_value(usage).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(serialized["hf_variables"], 8);
|
|
||||||
assert_eq!(serialized["lf_variables"], 4);
|
|
||||||
assert_eq!(serialized["active_tags"], 12);
|
|
||||||
assert_eq!(
|
|
||||||
serialized["active_tags"].as_i64().unwrap(),
|
|
||||||
serialized["hf_variables"].as_i64().unwrap()
|
|
||||||
+ serialized["lf_variables"].as_i64().unwrap()
|
|
||||||
);
|
|
||||||
assert_eq!(serialized["price_per_tag"], OFFICIAL_PRICE_PER_TAG_COP);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hf_variable_count_sql_scopes_enabled_variables_to_active_project_parents() {
|
|
||||||
let sql = hf_variables_count_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("FROM edge_variables ev"));
|
|
||||||
assert!(sql.contains("JOIN edge_devices ed ON ev.device_id = ed.id"));
|
|
||||||
assert!(sql.contains("JOIN edge_assets ea ON ed.asset_id = ea.id"));
|
|
||||||
assert!(sql.contains("ea.project_id = $1"));
|
|
||||||
assert!(sql.contains("ev.is_enabled = true"));
|
|
||||||
assert!(sql.contains("ed.is_enabled = true"));
|
|
||||||
assert!(sql.contains("ea.is_active = true"));
|
|
||||||
assert!(!sql.contains("ev.project_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lf_variable_count_sql_uses_real_manual_pwa_numeric_fields_only() {
|
|
||||||
let sql = lf_variables_count_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("FROM telemetry_raw tr"));
|
|
||||||
assert!(sql.contains("tr.project_id = $1"));
|
|
||||||
assert!(sql.contains("tr.source IN ('PWA', 'MANUAL')"));
|
|
||||||
assert!(sql.contains("jsonb_typeof(field_value) = 'number'"));
|
|
||||||
assert!(sql.contains("jsonb_each(tr.data)"));
|
|
||||||
assert!(sql.contains("jsonb_each(tr.data -> 'values')"));
|
|
||||||
assert!(sql.contains("jsonb_each(tr.data -> 'manual_fields')"));
|
|
||||||
assert!(sql.contains("COUNT(DISTINCT lower(field_name))"));
|
|
||||||
assert!(sql.contains("NOT IN"));
|
|
||||||
assert!(sql.contains("'timestamp'"));
|
|
||||||
assert!(sql.contains("'manual_fields'"));
|
|
||||||
assert!(sql.contains("lower(ev.alias) = lower(field_name)"));
|
|
||||||
assert!(!sql.contains("source = 'SCADA'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn free_commercial_profile_returns_non_collecting_zero_billing() {
|
|
||||||
let profile = CommercialProfileForBilling {
|
|
||||||
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
|
|
||||||
status: "approved".to_string(),
|
|
||||||
plan: "free".to_string(),
|
|
||||||
payment_method: Some("none".to_string()),
|
|
||||||
payment_terms_days: Some(0),
|
|
||||||
credit_limit_cop: None,
|
|
||||||
tg_limit: Some(0.0),
|
|
||||||
pbase_cop: 110_000,
|
|
||||||
discount_k: 0.1,
|
|
||||||
currency: "COP".to_string(),
|
|
||||||
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = build_profile_billing_response(
|
|
||||||
&profile,
|
|
||||||
BillingVariableCounts {
|
|
||||||
hf_variables: 20,
|
|
||||||
lf_variables: 10,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.total_annual_cop, 0);
|
|
||||||
assert_eq!(response.total_monthly_cop, 0);
|
|
||||||
assert_eq!(response.commercial_profile.plan, "free");
|
|
||||||
assert_eq!(response.commercial_profile.profile_complete, true);
|
|
||||||
assert_eq!(response.calculation_lines[0].amount_cop, 0);
|
|
||||||
assert_eq!(response.calculation_lines[0].kind, "free_non_collecting");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn operative_commercial_profile_uses_formula_not_flat_active_tags() {
|
|
||||||
let profile = CommercialProfileForBilling {
|
|
||||||
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
|
|
||||||
status: "active".to_string(),
|
|
||||||
plan: "operative".to_string(),
|
|
||||||
payment_method: Some("manual_invoice".to_string()),
|
|
||||||
payment_terms_days: Some(30),
|
|
||||||
credit_limit_cop: Some(10_000_000),
|
|
||||||
tg_limit: Some(50.0),
|
|
||||||
pbase_cop: 110_000,
|
|
||||||
discount_k: 0.1,
|
|
||||||
currency: "COP".to_string(),
|
|
||||||
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = build_profile_billing_response(
|
|
||||||
&profile,
|
|
||||||
BillingVariableCounts {
|
|
||||||
hf_variables: 2,
|
|
||||||
lf_variables: 5,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.active_tags, 7);
|
|
||||||
assert_eq!(response.commercial_profile.plan, "operative");
|
|
||||||
assert_eq!(response.formula.as_ref().unwrap().equivalent_tg, 4.0);
|
|
||||||
assert_ne!(response.total_annual_cop, 7 * 110_000);
|
|
||||||
assert_eq!(response.total_annual_cop, 383_042);
|
|
||||||
assert_eq!(response.calculation_lines[0].kind, "operative_formula");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_backed_operative_billing_usage_serializes_effective_formula_prices() {
|
|
||||||
let project_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
|
|
||||||
let now = Utc::now();
|
|
||||||
let subscription = Subscription {
|
|
||||||
id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
|
|
||||||
project_id,
|
|
||||||
tier: "starter".to_string(),
|
|
||||||
tag_limit: 50,
|
|
||||||
price_per_tag: 90_000,
|
|
||||||
min_annual_fee: 4_000_000,
|
|
||||||
status: "active".to_string(),
|
|
||||||
trial_ends_at: None,
|
|
||||||
current_period_start: now,
|
|
||||||
current_period_end: now,
|
|
||||||
notes: None,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
};
|
|
||||||
let profile = CommercialProfileForBilling {
|
|
||||||
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
|
|
||||||
status: "active".to_string(),
|
|
||||||
plan: "operative".to_string(),
|
|
||||||
payment_method: Some("manual_invoice".to_string()),
|
|
||||||
payment_terms_days: Some(30),
|
|
||||||
credit_limit_cop: Some(10_000_000),
|
|
||||||
tg_limit: Some(50.0),
|
|
||||||
pbase_cop: 110_000,
|
|
||||||
discount_k: 0.1,
|
|
||||||
currency: "COP".to_string(),
|
|
||||||
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
};
|
|
||||||
let counts = BillingVariableCounts {
|
|
||||||
hf_variables: 2,
|
|
||||||
lf_variables: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
let billing = build_profile_billing_response(&profile, counts).unwrap();
|
|
||||||
let usage = build_profile_billing_usage_response(
|
|
||||||
project_id,
|
|
||||||
"Demo Project".to_string(),
|
|
||||||
&subscription,
|
|
||||||
counts,
|
|
||||||
billing,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let serialized = serde_json::to_value(usage).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(serialized["commercial_profile"]["plan"], "operative");
|
|
||||||
assert_eq!(serialized["formula"]["effective_hf_price_cop"], 95_761);
|
|
||||||
assert_eq!(serialized["formula"]["effective_lf_price_cop"], 38_304);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enterprise_commercial_profile_uses_configured_override_source() {
|
|
||||||
let profile = CommercialProfileForBilling {
|
|
||||||
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
|
|
||||||
status: "approved".to_string(),
|
|
||||||
plan: "enterprise".to_string(),
|
|
||||||
payment_method: Some("enterprise_contract".to_string()),
|
|
||||||
payment_terms_days: Some(45),
|
|
||||||
credit_limit_cop: Some(50_000_000),
|
|
||||||
tg_limit: Some(100.0),
|
|
||||||
pbase_cop: 110_000,
|
|
||||||
discount_k: 0.1,
|
|
||||||
currency: "COP".to_string(),
|
|
||||||
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
|
|
||||||
enterprise_annual_cop: Some(24_000_000),
|
|
||||||
enterprise_monthly_cop: Some(2_000_000),
|
|
||||||
enterprise_override_reason: Some("Signed MSA".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = build_profile_billing_response(
|
|
||||||
&profile,
|
|
||||||
BillingVariableCounts {
|
|
||||||
hf_variables: 8,
|
|
||||||
lf_variables: 4,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.total_annual_cop, 24_000_000);
|
|
||||||
assert_eq!(response.total_monthly_cop, 2_000_000);
|
|
||||||
assert_eq!(response.calculation_lines[0].kind, "enterprise_override");
|
|
||||||
assert_eq!(
|
|
||||||
response.calculation_lines[0].source.as_deref(),
|
|
||||||
Some("Signed MSA")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::handlers::well_access;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -35,14 +34,22 @@ pub async fn list_downtime(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let access = sqlx::query(
|
||||||
&pool,
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
params.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para ver paradas de este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver paradas de este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
"SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC",
|
"SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC",
|
||||||
)
|
)
|
||||||
@@ -62,14 +69,22 @@ pub async fn create_downtime(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let access = sqlx::query(
|
||||||
&pool,
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
req.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para registrar paradas en este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar paradas en este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
r#"INSERT INTO operations_downtime (
|
r#"INSERT INTO operations_downtime (
|
||||||
asset_id, start_time, end_time, is_planned, reason, comments
|
asset_id, start_time, end_time, is_planned, reason, comments
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ pub async fn trigger_ota_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve project_id from agent_id (agents are keyed by project_id)
|
// Resolve project_id from agent_id (agents are keyed by project_id)
|
||||||
let project_id: Uuid = sqlx::query_scalar(
|
let project_id: Uuid = sqlx::query_scalar("SELECT id FROM edge_projects WHERE id = $1")
|
||||||
"SELECT id FROM edge_projects WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(agent_id)
|
.bind(agent_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)))?;
|
.ok_or_else(|| {
|
||||||
|
crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
|
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
|
||||||
let target_version = match req.version.as_deref() {
|
let target_version = match req.version.as_deref() {
|
||||||
@@ -71,8 +71,8 @@ pub async fn trigger_ota_update(
|
|||||||
|
|
||||||
// Build the download URL pointing to this backend's proxy endpoint.
|
// Build the download URL pointing to this backend's proxy endpoint.
|
||||||
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
|
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
|
||||||
let api_base = std::env::var("API_BASE_URL")
|
let api_base =
|
||||||
.unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
||||||
let download_url = format!(
|
let download_url = format!(
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}",
|
"{}/api/edge/download-agent?version={}&asset_type={}",
|
||||||
api_base.trim_end_matches('/'),
|
api_base.trim_end_matches('/'),
|
||||||
@@ -94,13 +94,17 @@ pub async fn trigger_ota_update(
|
|||||||
"issued_at": chrono::Utc::now().to_rfc3339(),
|
"issued_at": chrono::Utc::now().to_rfc3339(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(&command_payload)
|
let payload_bytes = serde_json::to_vec(&command_payload).map_err(|e| {
|
||||||
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?;
|
crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
state.mqtt_client
|
state
|
||||||
|
.mqtt_client
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
|
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
|
||||||
@@ -120,8 +124,6 @@ pub async fn trigger_ota_update(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::errors::AppError;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct LogQueryParams {
|
pub struct LogQueryParams {
|
||||||
pub level: Option<String>,
|
pub level: Option<String>,
|
||||||
@@ -130,6 +132,8 @@ 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
|
||||||
pub async fn list_agent_logs(
|
pub async fn list_agent_logs(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Handlers para activos (Pozos, Tanques, etc.)
|
//! Handlers para activos (Pozos, Tanques, etc.)
|
||||||
|
|
||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::handlers::well_access;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -34,12 +33,26 @@ pub async fn list_assets_by_project(
|
|||||||
claims: Claims,
|
claims: Claims,
|
||||||
Path(project_id): Path<Uuid>,
|
Path(project_id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
||||||
|
// Validar acceso al proyecto
|
||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
let assets = sqlx::query_as::<_, EdgeAsset>(well_access::project_assets_sql())
|
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
||||||
.bind(project_id)
|
|
||||||
.bind(user_id)
|
.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>(
|
||||||
|
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use chrono;
|
||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono;
|
|
||||||
|
|
||||||
async fn mark_build_dispatch_failed(
|
async fn mark_build_dispatch_failed(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use chrono;
|
||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|||||||
@@ -26,5 +26,4 @@ pub mod telemetry;
|
|||||||
pub mod totp;
|
pub mod totp;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod well_access;
|
|
||||||
pub mod well_tests;
|
pub mod well_tests;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::handlers::{audit_helper, well_access};
|
use crate::handlers::audit_helper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -9,7 +9,7 @@ use axum::{
|
|||||||
use bigdecimal::BigDecimal;
|
use bigdecimal::BigDecimal;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared_lib::models::{MovementType, OperationMovement};
|
use shared_lib::models::{MovementType, OperationMovement};
|
||||||
use sqlx::{PgPool, Postgres};
|
use sqlx::{PgPool, Postgres, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -19,7 +19,6 @@ pub struct CreateMovementRequest {
|
|||||||
pub start_time: chrono::DateTime<chrono::Utc>,
|
pub start_time: chrono::DateTime<chrono::Utc>,
|
||||||
pub end_time: chrono::DateTime<chrono::Utc>,
|
pub end_time: chrono::DateTime<chrono::Utc>,
|
||||||
pub volumen_neto: Option<BigDecimal>,
|
pub volumen_neto: Option<BigDecimal>,
|
||||||
pub client_id: Option<String>,
|
|
||||||
pub observaciones: Option<String>,
|
pub observaciones: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,115 +27,6 @@ pub struct MovementQueryParams {
|
|||||||
pub asset_id: Uuid,
|
pub asset_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn movement_details(
|
|
||||||
observaciones: Option<String>,
|
|
||||||
client_id: Option<String>,
|
|
||||||
created_by: &str,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"observaciones": observaciones,
|
|
||||||
"client_id": client_id,
|
|
||||||
"created_by": created_by
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_client_id(client_id: Option<String>) -> Result<Option<String>, AppError> {
|
|
||||||
let Some(client_id) = client_id else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let trimmed = client_id.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed = Uuid::parse_str(trimmed)
|
|
||||||
.map_err(|_| AppError::BadRequest("client_id debe ser un UUID válido".to_string()))?;
|
|
||||||
|
|
||||||
Ok(Some(parsed.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_existing_movement_by_client_id_sql() -> &'static str {
|
|
||||||
r#"SELECT *
|
|
||||||
FROM operation_movements
|
|
||||||
WHERE details->>'client_id' = $1
|
|
||||||
AND asset_id = $2
|
|
||||||
LIMIT 1"#
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_movement_sql() -> &'static str {
|
|
||||||
r#"INSERT INTO operation_movements (
|
|
||||||
asset_id, movement_type, start_time, end_time, volumen_neto, details
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (asset_id, (details->>'client_id'))
|
|
||||||
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL
|
|
||||||
DO NOTHING
|
|
||||||
RETURNING *"#
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn movement_details_preserve_client_id_for_pwa_idempotency() {
|
|
||||||
let details = movement_details(
|
|
||||||
Some("nota".to_string()),
|
|
||||||
Some("client-1".to_string()),
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(details["observaciones"], "nota");
|
|
||||||
assert_eq!(details["client_id"], "client-1");
|
|
||||||
assert_eq!(details["created_by"], "user-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_client_id_ignores_blank_values() {
|
|
||||||
assert_eq!(normalize_client_id(None).unwrap(), None);
|
|
||||||
assert_eq!(normalize_client_id(Some(" ".to_string())).unwrap(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_client_id_rejects_invalid_non_empty_values() {
|
|
||||||
let result = normalize_client_id(Some("not-a-uuid".to_string()));
|
|
||||||
|
|
||||||
assert!(matches!(result, Err(AppError::BadRequest(message)) if message.contains("UUID")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_client_id_canonicalizes_valid_uuid_values() {
|
|
||||||
let result =
|
|
||||||
normalize_client_id(Some(" 67E55044-10B1-426F-9247-BB680E5FE0C8 ".to_string()))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_existing_movement_by_client_id_filters_inside_details() {
|
|
||||||
let sql = find_existing_movement_by_client_id_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("FROM operation_movements"));
|
|
||||||
assert!(sql.contains("details->>'client_id' = $1"));
|
|
||||||
assert!(sql.contains("asset_id = $2"));
|
|
||||||
assert!(sql.contains("LIMIT 1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn insert_movement_sql_is_conflict_safe_for_client_id() {
|
|
||||||
let sql = insert_movement_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("ON CONFLICT (asset_id, (details->>'client_id'))"));
|
|
||||||
assert!(sql.contains("WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL"));
|
|
||||||
assert!(sql.contains("DO NOTHING"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/movements — Listar movimientos de un activo.
|
/// GET /api/movements — Listar movimientos de un activo.
|
||||||
pub async fn list_movements(
|
pub async fn list_movements(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
@@ -147,14 +37,22 @@ pub async fn list_movements(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let access = sqlx::query(
|
||||||
&pool,
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
params.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para ver movimientos de este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.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>(
|
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
"SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC",
|
"SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC",
|
||||||
)
|
)
|
||||||
@@ -175,58 +73,46 @@ pub async fn create_movement(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
let project_id = well_access::ensure_asset_access(
|
let project_row = sqlx::query(
|
||||||
&pool,
|
r#"SELECT ea.project_id FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
req.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para registrar movimientos en este activo",
|
|
||||||
)
|
)
|
||||||
.await?;
|
|
||||||
|
|
||||||
let client_id = normalize_client_id(req.client_id)?;
|
|
||||||
|
|
||||||
if let Some(client_id) = client_id.as_deref() {
|
|
||||||
let existing = sqlx::query_as::<Postgres, OperationMovement>(
|
|
||||||
find_existing_movement_by_client_id_sql(),
|
|
||||||
)
|
|
||||||
.bind(client_id)
|
|
||||||
.bind(req.asset_id)
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(movement) = existing {
|
let project_id: Uuid = match project_row {
|
||||||
return Ok((StatusCode::OK, Json(movement)));
|
Some(row) => row.get("project_id"),
|
||||||
}
|
None => {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar movimientos en este activo".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let details = movement_details(req.observaciones, client_id.clone(), &claims.sub);
|
let details = serde_json::json!({
|
||||||
|
"observaciones": req.observaciones,
|
||||||
|
"created_by": claims.sub
|
||||||
|
});
|
||||||
|
|
||||||
let inserted = sqlx::query_as::<Postgres, OperationMovement>(insert_movement_sql())
|
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.asset_id)
|
||||||
.bind(req.movement_type)
|
.bind(req.movement_type)
|
||||||
.bind(req.start_time)
|
.bind(req.start_time)
|
||||||
.bind(req.end_time)
|
.bind(req.end_time)
|
||||||
.bind(req.volumen_neto)
|
.bind(req.volumen_neto)
|
||||||
.bind(details)
|
.bind(details)
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(movement) = inserted else {
|
|
||||||
let Some(client_id) = client_id.as_deref() else {
|
|
||||||
return Err(AppError::Database(sqlx::Error::RowNotFound));
|
|
||||||
};
|
|
||||||
|
|
||||||
let movement = sqlx::query_as::<Postgres, OperationMovement>(
|
|
||||||
find_existing_movement_by_client_id_sql(),
|
|
||||||
)
|
|
||||||
.bind(client_id)
|
|
||||||
.bind(req.asset_id)
|
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
return Ok((StatusCode::OK, Json(movement)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Auditoría ────────────────────────────────────────────────────────────
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
audit_helper::log_audit(
|
audit_helper::log_audit(
|
||||||
&pool,
|
&pool,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
|
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
|
||||||
|
|
||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::errors::AppError;
|
|
||||||
use crate::handlers::well_access;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -20,22 +18,6 @@ use shared_lib::models::{
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn app_error_to_tuple(error: AppError) -> (StatusCode, String) {
|
|
||||||
match error {
|
|
||||||
AppError::NotFound(entity) => (StatusCode::NOT_FOUND, format!("{} no encontrado", entity)),
|
|
||||||
AppError::Forbidden(message) => (StatusCode::FORBIDDEN, message),
|
|
||||||
AppError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
|
|
||||||
AppError::Database(_) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Error interno de base de datos".to_string(),
|
|
||||||
),
|
|
||||||
AppError::Internal(_) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Error interno del servidor".to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/telemetry — Official LF telemetry channel for PWA/manual clients.
|
/// POST /api/telemetry — Official LF telemetry channel for PWA/manual clients.
|
||||||
///
|
///
|
||||||
/// SCADA/HF agents send telemetry by MQTT (`omnioil/telemetry/{project_id}`), never by HTTP.
|
/// SCADA/HF agents send telemetry by MQTT (`omnioil/telemetry/{project_id}`), never by HTTP.
|
||||||
@@ -55,14 +37,27 @@ pub async fn post_telemetry(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let asset_project_id = sqlx::query_scalar::<_, Uuid>(asset_project_sql())
|
||||||
&pool,
|
.bind(req.asset_id)
|
||||||
user_id,
|
.fetch_optional(&pool)
|
||||||
req.asset_id,
|
|
||||||
"Access denied to this asset's telemetry",
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(app_error_to_tuple)?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
|
||||||
|
|
||||||
|
let has_access = sqlx::query_scalar::<_, bool>(active_project_access_sql())
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(asset_project_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_access {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this asset's telemetry".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let result = sqlx::query(post_telemetry_insert_sql())
|
let result = sqlx::query(post_telemetry_insert_sql())
|
||||||
.bind(time)
|
.bind(time)
|
||||||
@@ -182,6 +177,18 @@ pub(crate) fn post_telemetry_insert_sql() -> &'static str {
|
|||||||
ON CONFLICT (time, asset_id) DO NOTHING"#
|
ON CONFLICT (time, asset_id) DO NOTHING"#
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn asset_project_sql() -> &'static str {
|
||||||
|
"SELECT project_id FROM edge_assets WHERE id = $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_project_access_sql() -> &'static str {
|
||||||
|
r#"SELECT true
|
||||||
|
FROM user_project_access
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
AND is_active = true"#
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_http_telemetry_source(
|
pub(crate) fn resolve_http_telemetry_source(
|
||||||
source: Option<TelemetrySource>,
|
source: Option<TelemetrySource>,
|
||||||
) -> Result<TelemetrySource, (StatusCode, String)> {
|
) -> Result<TelemetrySource, (StatusCode, String)> {
|
||||||
@@ -236,11 +243,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_telemetry_uses_well_scoped_asset_access() {
|
fn post_telemetry_checks_asset_then_active_user_project_access() {
|
||||||
let source = include_str!("telemetry.rs");
|
assert_eq!(
|
||||||
|
asset_project_sql(),
|
||||||
|
"SELECT project_id FROM edge_assets WHERE id = $1"
|
||||||
|
);
|
||||||
|
|
||||||
assert!(source.contains("well_access::ensure_asset_access"));
|
let access_sql = active_project_access_sql();
|
||||||
assert!(source.contains("Access denied to this asset's telemetry"));
|
assert!(access_sql.contains("FROM user_project_access"));
|
||||||
|
assert!(access_sql.contains("user_id = $1"));
|
||||||
|
assert!(access_sql.contains("project_id = $2"));
|
||||||
|
assert!(access_sql.contains("is_active = true"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,13 @@
|
|||||||
//!
|
//!
|
||||||
//! Edge Agent ──► GET /api/edge/update-info (solo metadatos)
|
//! Edge Agent ──► GET /api/edge/update-info (solo metadatos)
|
||||||
//! Edge Agent ──► GET /api/edge/download-agent ──► MinIO (sin puerto expuesto)
|
//! Edge Agent ──► GET /api/edge/download-agent ──► MinIO (sin puerto expuesto)
|
||||||
//! │ verifica firma HMAC (ts+sig)
|
//! │ verifica Bearer token
|
||||||
//! │ hace streaming desde MinIO interno
|
//! │ hace streaming desde MinIO interno
|
||||||
//! └──► Edge Agent recibe el binario
|
//! └──► Edge Agent recibe el binario
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! MinIO NUNCA tiene puerto expuesto al exterior (no hay mapeo en docker-compose.yml).
|
//! MinIO NUNCA tiene puerto expuesto al exterior (no hay mapeo en docker-compose.yml).
|
||||||
//! Todo el tráfico pasa por el backend API que valida la firma HMAC de la URL.
|
//! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar.
|
||||||
//! La firma usa AGENT_DOWNLOAD_SECRET y expira a los 5 minutos.
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -23,97 +22,12 @@ use axum::{
|
|||||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
/// Verifica que la URL firmada sea válida y no haya expirado.
|
|
||||||
/// Si AGENT_DOWNLOAD_SECRET no está configurado, opera en modo compatibilidad (sin firma).
|
|
||||||
fn verify_signed_url(message: &str, timestamp: &str, signature: &str, max_age_secs: u64) -> Result<(), Response> {
|
|
||||||
let secret = match std::env::var("AGENT_DOWNLOAD_SECRET") {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
warn!("AGENT_DOWNLOAD_SECRET not set — download authentication disabled");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let ts: u64 = timestamp.parse().map_err(|_| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid timestamp" }))).into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if now.saturating_sub(ts) > max_age_secs {
|
|
||||||
return Err(
|
|
||||||
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Signature expired" }))).into_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
|
||||||
.map_err(|_| {
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "HMAC error" }))).into_response()
|
|
||||||
})?;
|
|
||||||
mac.update(message.as_bytes());
|
|
||||||
let expected = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
// Comparación en tiempo constante
|
|
||||||
if expected.as_bytes() != signature.as_bytes() {
|
|
||||||
return Err(
|
|
||||||
(StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Invalid signature" }))).into_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Genera una URL firmada para descarga del binario.
|
|
||||||
/// Si AGENT_DOWNLOAD_SECRET no está configurado, devuelve URL sin firma (modo compatibilidad).
|
|
||||||
fn generate_signed_url(api_base: &str, version: &str, asset_type: &str) -> String {
|
|
||||||
let secret = match std::env::var("AGENT_DOWNLOAD_SECRET") {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}",
|
|
||||||
api_base.trim_end_matches('/'),
|
|
||||||
version,
|
|
||||||
asset_type,
|
|
||||||
);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let ts = now.to_string();
|
|
||||||
let message = format!("{}:{}", version, ts);
|
|
||||||
|
|
||||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
|
||||||
.expect("HMAC key init");
|
|
||||||
mac.update(message.as_bytes());
|
|
||||||
let sig = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}&ts={}&sig={}",
|
|
||||||
api_base.trim_end_matches('/'),
|
|
||||||
version,
|
|
||||||
asset_type,
|
|
||||||
ts,
|
|
||||||
sig,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -122,10 +36,6 @@ pub struct UpdateQuery {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
/// Versión específica para rollback/pin (ej. "0.1.9"). Opcional.
|
/// Versión específica para rollback/pin (ej. "0.1.9"). Opcional.
|
||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
/// Timestamp UNIX para firma HMAC.
|
|
||||||
pub ts: Option<String>,
|
|
||||||
/// HMAC-SHA256 signature: hex(version:timestamp).
|
|
||||||
pub sig: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -134,10 +44,6 @@ pub struct DownloadQuery {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
/// Tipo de asset: "msi" | "exe" (default: "msi").
|
/// Tipo de asset: "msi" | "exe" (default: "msi").
|
||||||
pub asset_type: Option<String>,
|
pub asset_type: Option<String>,
|
||||||
/// Timestamp UNIX para firma HMAC.
|
|
||||||
pub ts: Option<String>,
|
|
||||||
/// HMAC-SHA256 signature: hex(version:timestamp).
|
|
||||||
pub sig: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -161,7 +67,7 @@ fn err(status: StatusCode, msg: &str) -> Response {
|
|||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/edge/update-info?version=<current>[&target=<pin>][&ts=<unix>&sig=<hex>]
|
/// GET /api/edge/update-info?version=<current>[&target=<pin>]
|
||||||
///
|
///
|
||||||
/// Devuelve metadatos de la actualización disponible. La `download_url` apunta
|
/// Devuelve metadatos de la actualización disponible. La `download_url` apunta
|
||||||
/// al endpoint `/api/edge/download-agent` del propio backend, no a MinIO.
|
/// al endpoint `/api/edge/download-agent` del propio backend, no a MinIO.
|
||||||
@@ -169,19 +75,6 @@ pub async fn get_update_info(
|
|||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
Query(params): Query<UpdateQuery>,
|
Query(params): Query<UpdateQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 0. Verificar firma HMAC (si AGENT_DOWNLOAD_SECRET está configurado)
|
|
||||||
if let (Some(ts), Some(sig)) = (¶ms.ts, ¶ms.sig) {
|
|
||||||
let msg = format!("{}:{}", params.version, ts);
|
|
||||||
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
|
|
||||||
).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Parsear versión actual
|
// 1. Parsear versión actual
|
||||||
let current = match Version::parse(¶ms.version) {
|
let current = match Version::parse(¶ms.version) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -235,10 +128,14 @@ pub async fn get_update_info(
|
|||||||
// 4. SHA-256: leerlo desde MinIO internamente (el agente no necesita acceder a MinIO)
|
// 4. SHA-256: leerlo desde MinIO internamente (el agente no necesita acceder a MinIO)
|
||||||
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
|
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
|
||||||
|
|
||||||
// 5. download_url apunta al endpoint del BACKEND con firma HMAC, no a MinIO directamente
|
// 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente
|
||||||
let api_base =
|
let api_base =
|
||||||
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
||||||
let download_url = generate_signed_url(&api_base, &latest.to_string(), "msi");
|
let download_url = format!(
|
||||||
|
"{}/api/edge/download-agent?version={}&asset_type=msi",
|
||||||
|
api_base.trim_end_matches('/'),
|
||||||
|
latest
|
||||||
|
);
|
||||||
|
|
||||||
info!("Agent v{} → update available: v{}", current, latest);
|
info!("Agent v{} → update available: v{}", current, latest);
|
||||||
|
|
||||||
@@ -252,7 +149,7 @@ pub async fn get_update_info(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>&ts=<unix>&sig=<hex>
|
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>
|
||||||
///
|
///
|
||||||
/// Proxy autenticado: descarga el binario desde MinIO (red interna) y lo
|
/// Proxy autenticado: descarga el binario desde MinIO (red interna) y lo
|
||||||
/// envía al agente en streaming. El agente nunca ve la URL de MinIO.
|
/// envía al agente en streaming. El agente nunca ve la URL de MinIO.
|
||||||
@@ -260,23 +157,8 @@ pub async fn download_agent(
|
|||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
Query(params): Query<DownloadQuery>,
|
Query(params): Query<DownloadQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 0. Verificar firma HMAC (si AGENT_DOWNLOAD_SECRET está configurado)
|
|
||||||
let asset_type = params.asset_type.as_deref().unwrap_or("msi");
|
let asset_type = params.asset_type.as_deref().unwrap_or("msi");
|
||||||
let version_clean = params.version.trim_start_matches('v');
|
let version = params.version.trim_start_matches('v');
|
||||||
|
|
||||||
if let (Some(ts), Some(sig)) = (¶ms.ts, ¶ms.sig) {
|
|
||||||
let msg = format!("{}:{}", version_clean, ts);
|
|
||||||
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
|
|
||||||
).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = version_clean;
|
|
||||||
|
|
||||||
let minio_internal =
|
let minio_internal =
|
||||||
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
use crate::auth::{AdminClaims, Claims};
|
|
||||||
use crate::errors::AppError;
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use shared_lib::models::EdgeAsset;
|
|
||||||
use sqlx::{PgPool, Row};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SetUserWellsRequest {
|
|
||||||
pub well_ids: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct SetUserWellsResponse {
|
|
||||||
pub message: String,
|
|
||||||
pub count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn project_access_sql() -> &'static str {
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM user_project_access
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND project_id = $2
|
|
||||||
AND is_active = true"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_well_assignments_exist_sql() -> &'static str {
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM user_well_access
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND project_id = $2
|
|
||||||
AND is_active = true
|
|
||||||
LIMIT 1"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asset_access_sql() -> &'static str {
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa
|
|
||||||
ON upa.project_id = ea.project_id
|
|
||||||
AND upa.user_id = $2
|
|
||||||
AND upa.is_active = true
|
|
||||||
WHERE ea.id = $1
|
|
||||||
AND (
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa_any
|
|
||||||
WHERE uwa_any.user_id = $2
|
|
||||||
AND uwa_any.project_id = ea.project_id
|
|
||||||
AND uwa_any.is_active = true
|
|
||||||
)
|
|
||||||
OR ea.asset_type <> 'POZO'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa
|
|
||||||
WHERE uwa.user_id = $2
|
|
||||||
AND uwa.project_id = ea.project_id
|
|
||||||
AND uwa.well_asset_id = ea.id
|
|
||||||
AND uwa.is_active = true
|
|
||||||
)
|
|
||||||
)"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn project_assets_sql() -> &'static str {
|
|
||||||
r#"SELECT ea.*
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa
|
|
||||||
ON upa.project_id = ea.project_id
|
|
||||||
AND upa.user_id = $2
|
|
||||||
AND upa.is_active = true
|
|
||||||
WHERE ea.project_id = $1
|
|
||||||
AND (
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa_any
|
|
||||||
WHERE uwa_any.user_id = $2
|
|
||||||
AND uwa_any.project_id = ea.project_id
|
|
||||||
AND uwa_any.is_active = true
|
|
||||||
)
|
|
||||||
OR ea.asset_type <> 'POZO'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa
|
|
||||||
WHERE uwa.user_id = $2
|
|
||||||
AND uwa.project_id = ea.project_id
|
|
||||||
AND uwa.well_asset_id = ea.id
|
|
||||||
AND uwa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY ea.created_at DESC"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_project_access(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let access = sqlx::query(project_access_sql())
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if access.is_none() {
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Sin acceso a este proyecto".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_asset_access(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
asset_id: Uuid,
|
|
||||||
forbidden_message: &str,
|
|
||||||
) -> Result<Uuid, AppError> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"SELECT ea.project_id
|
|
||||||
FROM edge_assets ea
|
|
||||||
WHERE ea.id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(asset_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or(AppError::NotFound("Activo".to_string()))?;
|
|
||||||
|
|
||||||
let project_id: Uuid = row.get("project_id");
|
|
||||||
let access = sqlx::query(asset_access_sql())
|
|
||||||
.bind(asset_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if access.is_none() {
|
|
||||||
return Err(AppError::Forbidden(forbidden_message.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(project_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_claim_user_id(claims: &Claims) -> Result<Uuid, AppError> {
|
|
||||||
Uuid::parse_str(&claims.sub).map_err(|_| AppError::BadRequest("User ID inválido".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_superadmin(claims: &Claims) -> bool {
|
|
||||||
claims.role == "superadmin"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_admin_project_access(
|
|
||||||
pool: &PgPool,
|
|
||||||
admin: &AdminClaims,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if is_superadmin(&admin.0) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_project_access(pool, parse_claim_user_id(&admin.0)?, project_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_project_wells(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
admin: AdminClaims,
|
|
||||||
Path(project_id): Path<Uuid>,
|
|
||||||
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
|
||||||
ensure_admin_project_access(&pool, &admin, project_id).await?;
|
|
||||||
|
|
||||||
let wells = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
r#"SELECT *
|
|
||||||
FROM edge_assets
|
|
||||||
WHERE project_id = $1
|
|
||||||
AND asset_type = 'POZO'
|
|
||||||
AND is_active = true
|
|
||||||
ORDER BY name ASC"#,
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(wells))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_wells(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
admin: AdminClaims,
|
|
||||||
Path(user_id): Path<Uuid>,
|
|
||||||
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
|
||||||
if is_superadmin(&admin.0) {
|
|
||||||
let wells = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
r#"SELECT ea.*
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_well_access uwa ON uwa.well_asset_id = ea.id
|
|
||||||
WHERE uwa.user_id = $1
|
|
||||||
AND uwa.is_active = true
|
|
||||||
ORDER BY ea.name ASC"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
return Ok(Json(wells));
|
|
||||||
}
|
|
||||||
|
|
||||||
let admin_id = parse_claim_user_id(&admin.0)?;
|
|
||||||
|
|
||||||
let wells = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
r#"SELECT ea.*
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_well_access uwa ON uwa.well_asset_id = ea.id
|
|
||||||
JOIN user_project_access admin_upa ON admin_upa.project_id = ea.project_id
|
|
||||||
WHERE uwa.user_id = $1
|
|
||||||
AND uwa.is_active = true
|
|
||||||
AND admin_upa.user_id = $2
|
|
||||||
AND admin_upa.is_active = true
|
|
||||||
ORDER BY ea.name ASC"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(admin_id)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(wells))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_user_wells(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
admin: AdminClaims,
|
|
||||||
Path(user_id): Path<Uuid>,
|
|
||||||
Json(req): Json<SetUserWellsRequest>,
|
|
||||||
) -> Result<Json<SetUserWellsResponse>, AppError> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
if is_superadmin(&admin.0) {
|
|
||||||
sqlx::query("DELETE FROM user_well_access WHERE user_id = $1")
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
let admin_id = parse_claim_user_id(&admin.0)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"DELETE FROM user_well_access uwa
|
|
||||||
USING user_project_access admin_upa
|
|
||||||
WHERE uwa.user_id = $1
|
|
||||||
AND admin_upa.user_id = $2
|
|
||||||
AND admin_upa.project_id = uwa.project_id
|
|
||||||
AND admin_upa.is_active = true"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(admin_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for well_id in &req.well_ids {
|
|
||||||
let well = sqlx::query(
|
|
||||||
r#"SELECT id, project_id
|
|
||||||
FROM edge_assets
|
|
||||||
WHERE id = $1
|
|
||||||
AND asset_type = 'POZO'
|
|
||||||
AND is_active = true"#,
|
|
||||||
)
|
|
||||||
.bind(well_id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.ok_or(AppError::BadRequest(
|
|
||||||
"Solo se pueden asignar pozos activos".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let project_id: Uuid = well.get("project_id");
|
|
||||||
ensure_admin_project_access(&pool, &admin, project_id).await?;
|
|
||||||
ensure_project_access(&pool, user_id, project_id).await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"INSERT INTO user_well_access (user_id, project_id, well_asset_id, is_active)
|
|
||||||
VALUES ($1, $2, $3, true)
|
|
||||||
ON CONFLICT (user_id, well_asset_id)
|
|
||||||
DO UPDATE SET project_id = EXCLUDED.project_id,
|
|
||||||
is_active = true,
|
|
||||||
updated_at = NOW()"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.bind(well_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(Json(SetUserWellsResponse {
|
|
||||||
message: "Pozos asignados exitosamente".to_string(),
|
|
||||||
count: req.well_ids.len(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn asset_access_is_project_access_with_optional_well_scope() {
|
|
||||||
let sql = asset_access_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("JOIN user_project_access upa"));
|
|
||||||
assert!(sql.contains("NOT EXISTS"));
|
|
||||||
assert!(sql.contains("FROM user_well_access uwa_any"));
|
|
||||||
assert!(sql.contains("ea.asset_type <> 'POZO'"));
|
|
||||||
assert!(sql.contains("uwa.well_asset_id = ea.id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_assets_are_filtered_by_well_assignments_when_present() {
|
|
||||||
let sql = project_assets_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("SELECT ea.*"));
|
|
||||||
assert!(sql.contains("ea.project_id = $1"));
|
|
||||||
assert!(sql.contains("upa.user_id = $2"));
|
|
||||||
assert!(sql.contains("ea.asset_type <> 'POZO'"));
|
|
||||||
assert!(sql.contains("uwa.well_asset_id = ea.id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_superadmin_replacement_deletes_only_visible_scope() {
|
|
||||||
let source = include_str!("well_access.rs");
|
|
||||||
|
|
||||||
assert!(source.contains("DELETE FROM user_well_access uwa"));
|
|
||||||
assert!(source.contains("USING user_project_access admin_upa"));
|
|
||||||
assert!(source.contains("admin_upa.project_id = uwa.project_id"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,14 @@ pub mod access_request_emails;
|
|||||||
pub mod api_version;
|
pub mod api_version;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod billing_rules;
|
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod invitation_tokens;
|
pub mod invitation_tokens;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod mqtt_loop;
|
pub mod mqtt_telemetry;
|
||||||
pub mod notifier;
|
pub mod notifier;
|
||||||
pub mod ws;
|
|
||||||
pub mod passwords;
|
pub mod passwords;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod smtp_mailer;
|
pub mod smtp_mailer;
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
|
extract::{
|
||||||
|
Query, State,
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
routing::{get, patch, post, put},
|
routing::{get, patch, post, put},
|
||||||
};
|
};
|
||||||
use backend_api::{
|
use backend_api::{
|
||||||
AppState, TelemetryEvent, api_version, crypto, db, handlers, metrics, mqtt_loop,
|
AppState, TelemetryEvent, api_version, auth, crypto, db, handlers, metrics, mqtt_telemetry,
|
||||||
rate_limiter, watchdog, ws,
|
notifier, rate_limiter, watchdog,
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::Row;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
@@ -20,7 +30,7 @@ async fn main() {
|
|||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer().json().flatten_event(false))
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Starting Backend API...");
|
tracing::info!("Starting Backend API...");
|
||||||
@@ -116,27 +126,294 @@ async fn main() {
|
|||||||
mqtt_options.set_credentials(u, p);
|
mqtt_options.set_credentials(u, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mqtt_client, eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
||||||
|
|
||||||
let (tx, _) = broadcast::channel::<TelemetryEvent>(1000);
|
let (tx, _) = broadcast::channel::<TelemetryEvent>(1000);
|
||||||
|
let device_resolver_cache = mqtt_telemetry::new_device_resolver_cache();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
mqtt_client: mqtt_client.clone(),
|
mqtt_client: mqtt_client.clone(),
|
||||||
tx: tx.clone(),
|
tx: tx.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn MQTT event loop (extraído a mqtt_loop.rs)
|
// Spawn MQTT event loop para procesar mensajes
|
||||||
let mqtt_client_for_loop = mqtt_client.clone();
|
let tx_relay = tx.clone();
|
||||||
let mqtt_pool = pool.clone();
|
let prov_pool = pool.clone();
|
||||||
let mqtt_tx = tx.clone();
|
let prov_mqtt_client = mqtt_client.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
mqtt_loop::run_event_loop(
|
loop {
|
||||||
eventloop,
|
match eventloop.poll().await {
|
||||||
mqtt_client_for_loop,
|
Ok(notification) => {
|
||||||
mqtt_pool,
|
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) =
|
||||||
mqtt_tx,
|
notification
|
||||||
|
{
|
||||||
|
let topic = publish.topic.as_str();
|
||||||
|
|
||||||
|
if topic.starts_with("omnioil/status/") {
|
||||||
|
// ── Estado inmediato del agente (LWT o reconexión) ─────────────
|
||||||
|
// Permite que la UI muestre "offline" en segundos, no minutos.
|
||||||
|
let project_id_str = topic
|
||||||
|
.strip_prefix("omnioil/status/")
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let tx_ref = tx_relay.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(msg) =
|
||||||
|
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
||||||
|
{
|
||||||
|
let status =
|
||||||
|
msg["status"].as_str().unwrap_or("offline").to_string();
|
||||||
|
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
|
||||||
|
{
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"UPDATE edge_projects
|
||||||
|
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
|
||||||
|
WHERE id = $2"#,
|
||||||
)
|
)
|
||||||
|
.bind(&status)
|
||||||
|
.bind(project_uuid)
|
||||||
|
.execute(&pool_ref)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
|
// Notificar UI en tiempo real
|
||||||
|
let event = TelemetryEvent {
|
||||||
|
project_id: Arc::from(project_id_str.to_lowercase()),
|
||||||
|
payload: Arc::from(
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "agent_status",
|
||||||
|
"status": status,
|
||||||
|
"timestamp": msg["timestamp"]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let _ = tx_ref.send(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if topic.starts_with("omnioil/health/") {
|
||||||
|
// ── Health report periódico del agente ─────────────────────────
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let tx_ref = tx_relay.clone();
|
||||||
|
let project_id_str = topic
|
||||||
|
.strip_prefix("omnioil/health/")
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(report) = serde_json::from_slice::<
|
||||||
|
shared_lib::mqtt_messages::HealthReport,
|
||||||
|
>(&payload_bytes)
|
||||||
|
{
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE edge_projects SET last_health_report = $1 WHERE id = $2",
|
||||||
|
)
|
||||||
|
.bind(serde_json::to_value(&report).unwrap_or_default())
|
||||||
|
.bind(report.project_id)
|
||||||
|
.execute(&pool_ref)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Relay a UI
|
||||||
|
let event = TelemetryEvent {
|
||||||
|
project_id: Arc::from(project_id_str.to_lowercase()),
|
||||||
|
payload: Arc::from(
|
||||||
|
serde_json::to_string(&report).unwrap_or_default(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let _ = tx_ref.send(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if topic.starts_with("omnioil/logs/") {
|
||||||
|
// ── Logs del agente → insertar en DB ──────────────────────────
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(entry) = serde_json::from_slice::<
|
||||||
|
shared_lib::mqtt_messages::AgentLogEntry,
|
||||||
|
>(&payload_bytes)
|
||||||
|
{
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"INSERT INTO agent_logs
|
||||||
|
(project_id, level, category, message, context, agent_hostname, timestamp)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
|
||||||
|
)
|
||||||
|
.bind(entry.project_id)
|
||||||
|
.bind(&entry.level)
|
||||||
|
.bind(&entry.category)
|
||||||
|
.bind(&entry.message)
|
||||||
|
.bind(entry.context.unwrap_or(serde_json::json!({})))
|
||||||
|
.bind(&entry.agent_hostname)
|
||||||
|
.bind(entry.timestamp)
|
||||||
|
.execute(&pool_ref)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if topic.starts_with("omnioil/alarms/") {
|
||||||
|
// ── Alarma MQTT → dispatch notificaciones ─────────────────────
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let tx_ref = tx_relay.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
let project_id_str = topic
|
||||||
|
.strip_prefix("omnioil/alarms/")
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(event) = serde_json::from_slice::<
|
||||||
|
shared_lib::mqtt_messages::AlarmEvent,
|
||||||
|
>(&payload_bytes)
|
||||||
|
{
|
||||||
|
let alarm = notifier::AlarmNotification {
|
||||||
|
project_id: event.project_id,
|
||||||
|
asset_id: None,
|
||||||
|
variable: event
|
||||||
|
.variable_alias
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
value: event.current_value.unwrap_or(0.0),
|
||||||
|
alarm_type: format!("{:?}", event.alarm_type),
|
||||||
|
message: event.message.unwrap_or_default(),
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
};
|
||||||
|
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
|
||||||
|
project_id_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if topic.starts_with("omnioil/opcua-scan/") {
|
||||||
|
// ── OPC UA scan result from agent → store in DB ───────────────
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(result) =
|
||||||
|
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
||||||
|
{
|
||||||
|
let scan_id_str = result
|
||||||
|
.get("scan_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let status = result
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("failed");
|
||||||
|
let error_msg = result.get("error").and_then(|v| v.as_str());
|
||||||
|
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"UPDATE opcua_scan_results
|
||||||
|
SET status = $1, result = $2, error_message = $3
|
||||||
|
WHERE id = $4"#,
|
||||||
|
)
|
||||||
|
.bind(status)
|
||||||
|
.bind(&result)
|
||||||
|
.bind(error_msg)
|
||||||
|
.bind(scan_id)
|
||||||
|
.execute(&pool_ref)
|
||||||
|
.await;
|
||||||
|
tracing::info!(
|
||||||
|
"✅ OPC UA scan result stored: {} ({})",
|
||||||
|
scan_id,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if topic.starts_with("omnioil/telemetry/") {
|
||||||
|
let Some(topic_project_id) =
|
||||||
|
mqtt_telemetry::telemetry_project_id_from_topic(topic)
|
||||||
|
else {
|
||||||
|
tracing::warn!(
|
||||||
|
topic = %topic,
|
||||||
|
"MQTT telemetry dropped: invalid project_id in topic"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let tx_ref = tx_relay.clone();
|
||||||
|
let cache_ref = device_resolver_cache.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = mqtt_telemetry::handle_edge_telemetry(
|
||||||
|
&pool_ref,
|
||||||
|
&tx_ref,
|
||||||
|
&cache_ref,
|
||||||
|
topic_project_id,
|
||||||
|
&payload_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("⚠️ MQTT telemetry handling failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if topic.starts_with("omnioil/") {
|
||||||
|
// ── Relay genérico: telemetría, alarmas, deploys → WebSocket ──
|
||||||
|
let topic_parts: Vec<&str> = topic.split('/').collect();
|
||||||
|
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
|
||||||
|
|
||||||
|
metrics::record_telemetry_received();
|
||||||
|
let decompressed =
|
||||||
|
shared_lib::compression::decompress_if_needed(&publish.payload);
|
||||||
|
|
||||||
|
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
|
||||||
|
let event = TelemetryEvent {
|
||||||
|
project_id: Arc::from(project_id.to_lowercase()),
|
||||||
|
payload: Arc::from(payload_str),
|
||||||
|
};
|
||||||
|
match tx_relay.send(event) {
|
||||||
|
Ok(receivers) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"📥 MQTT relayed to {} WS listeners",
|
||||||
|
receivers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("⚠️ No WS listeners: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if topic.starts_with("provision/request/") {
|
||||||
|
// ── Provisioning MQTT ──────────────────────────────────────────
|
||||||
|
let project_id_str = topic
|
||||||
|
.strip_prefix("provision/request/")
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let client_ref = prov_mqtt_client.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handlers::provisioning::handle_mqtt_provisioning(
|
||||||
|
&pool_ref,
|
||||||
|
&client_ref,
|
||||||
|
&project_id_str,
|
||||||
|
&payload_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"❌ Error en provisioning MQTT para proyecto {}: {}",
|
||||||
|
project_id_str,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("MQTT connection error: {:?}", e);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suscribirse a todos los topics de omnioil y de provisioning
|
// Suscribirse a todos los topics de omnioil y de provisioning
|
||||||
@@ -337,7 +614,6 @@ async fn main() {
|
|||||||
let auth_rate_limiter = rate_limiter::new_rate_limiter(10);
|
let auth_rate_limiter = rate_limiter::new_rate_limiter(10);
|
||||||
let register_rate_limiter = rate_limiter::new_rate_limiter(5);
|
let register_rate_limiter = rate_limiter::new_rate_limiter(5);
|
||||||
let request_access_rate_limiter = rate_limiter::new_rate_limiter(5);
|
let request_access_rate_limiter = rate_limiter::new_rate_limiter(5);
|
||||||
let setup_rate_limiter = rate_limiter::new_rate_limiter(5);
|
|
||||||
|
|
||||||
// Sub-router de auth con rate limiting por endpoint
|
// Sub-router de auth con rate limiting por endpoint
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
@@ -387,13 +663,7 @@ async fn main() {
|
|||||||
get(handlers::invitations::validate_invitation),
|
get(handlers::invitations::validate_invitation),
|
||||||
)
|
)
|
||||||
.route("/setup/status", get(handlers::auth::get_setup_status))
|
.route("/setup/status", get(handlers::auth::get_setup_status))
|
||||||
.route(
|
.route("/setup/register", post(handlers::auth::setup_register));
|
||||||
"/setup/register",
|
|
||||||
post(handlers::auth::setup_register).layer(axum::middleware::from_fn_with_state(
|
|
||||||
setup_rate_limiter,
|
|
||||||
rate_limiter::rate_limit_by_ip,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rutas de gestión de tokens de provisioning (solo admin)
|
// Rutas de gestión de tokens de provisioning (solo admin)
|
||||||
let provisioning_routes = Router::new().route(
|
let provisioning_routes = Router::new().route(
|
||||||
@@ -427,14 +697,6 @@ async fn main() {
|
|||||||
"/users/{id}/projects",
|
"/users/{id}/projects",
|
||||||
put(handlers::users::set_user_projects),
|
put(handlers::users::set_user_projects),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/users/{id}/wells",
|
|
||||||
get(handlers::well_access::get_user_wells).put(handlers::well_access::set_user_wells),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/projects/{project_id}/wells",
|
|
||||||
get(handlers::well_access::list_project_wells),
|
|
||||||
)
|
|
||||||
.route("/roles", get(handlers::users::list_roles))
|
.route("/roles", get(handlers::users::list_roles))
|
||||||
// Deprecated aliases: provider-only routes live under /api/platform.
|
// Deprecated aliases: provider-only routes live under /api/platform.
|
||||||
.route(
|
.route(
|
||||||
@@ -449,14 +711,6 @@ async fn main() {
|
|||||||
"/access-requests/{id}/review",
|
"/access-requests/{id}/review",
|
||||||
post(handlers::access_requests::mark_access_request_in_review),
|
post(handlers::access_requests::mark_access_request_in_review),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/access-requests/{id}/classification",
|
|
||||||
put(handlers::access_requests::classify_access_request),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/commercial-profiles/{id}",
|
|
||||||
get(handlers::access_requests::get_commercial_profile),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/access-requests/{id}/approve",
|
"/access-requests/{id}/approve",
|
||||||
post(handlers::access_requests::approve_access_request),
|
post(handlers::access_requests::approve_access_request),
|
||||||
@@ -484,14 +738,6 @@ async fn main() {
|
|||||||
"/access-requests/{id}/review",
|
"/access-requests/{id}/review",
|
||||||
post(handlers::access_requests::mark_access_request_in_review),
|
post(handlers::access_requests::mark_access_request_in_review),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/access-requests/{id}/classification",
|
|
||||||
put(handlers::access_requests::classify_access_request),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/commercial-profiles/{id}",
|
|
||||||
get(handlers::access_requests::get_commercial_profile),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/access-requests/{id}/approve",
|
"/access-requests/{id}/approve",
|
||||||
post(handlers::access_requests::approve_access_request),
|
post(handlers::access_requests::approve_access_request),
|
||||||
@@ -697,8 +943,9 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
// Edge System API
|
// Edge System API
|
||||||
.nest("/api/edge", edge_routes)
|
.nest("/api/edge", edge_routes)
|
||||||
// WebSocket Stream (Relay de Telemetría) — extraído a ws.rs
|
// WebSocket Stream (Relay de Telemetría)
|
||||||
.merge(ws::routes())
|
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
|
||||||
|
.route("/api/ws/telemetry", get(ws_telemetry_handler))
|
||||||
// Global rate limiter: 60 req/min per IP
|
// Global rate limiter: 60 req/min per IP
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
api_rate_limiter,
|
api_rate_limiter,
|
||||||
@@ -721,3 +968,298 @@ async fn main() {
|
|||||||
async fn root() -> axum::Json<serde_json::Value> {
|
async fn root() -> axum::Json<serde_json::Value> {
|
||||||
axum::Json(serde_json::json!({"status": "ok"}))
|
axum::Json(serde_json::json!({"status": "ok"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WsParams {
|
||||||
|
project_id: Option<String>,
|
||||||
|
/// Short-lived opaque one-time ticket. Never pass the primary JWT in the URL.
|
||||||
|
ticket: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateWsTicketRequest {
|
||||||
|
project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateWsTicketResponse {
|
||||||
|
ticket: String,
|
||||||
|
expires_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_TICKET_TTL_SECONDS: u64 = 60;
|
||||||
|
|
||||||
|
async fn create_ws_telemetry_ticket(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: auth::Claims,
|
||||||
|
axum::Json(req): axum::Json<CreateWsTicketRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
|
||||||
|
Ok(user_id) => user_id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(serde_json::json!({"error": "Usuario inválido"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
|
||||||
|
Ok(project_id) => project_id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
axum::Json(serde_json::json!({"error": "project_id inválido"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_can_access_project(&state.pool, user_id, project_id).await {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
axum::Json(serde_json::json!({"error": "Access denied to this project"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(error = %error, "Failed to check WebSocket project access");
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let token_hash = hash_ws_ticket(&ticket);
|
||||||
|
let expires_in = WS_TICKET_TTL_SECONDS as i64;
|
||||||
|
|
||||||
|
if let Err(error) = sqlx::query(
|
||||||
|
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(format!("{} seconds", expires_in))
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
axum::Json(CreateWsTicketResponse {
|
||||||
|
ticket,
|
||||||
|
expires_in: WS_TICKET_TTL_SECONDS,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_telemetry_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
query: Query<WsParams>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let ticket = match &query.ticket {
|
||||||
|
Some(ticket) => ticket.as_str(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(
|
||||||
|
serde_json::json!({"error": "Ticket requerido para conexión WebSocket"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
|
||||||
|
{
|
||||||
|
Ok(project_id) => project_id,
|
||||||
|
Err(status) => {
|
||||||
|
let message = if status == axum::http::StatusCode::FORBIDDEN {
|
||||||
|
"Ticket no corresponde al proyecto solicitado"
|
||||||
|
} else {
|
||||||
|
"Ticket inválido o expirado"
|
||||||
|
};
|
||||||
|
return (status, axum::Json(serde_json::json!({"error": message}))).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id = Some(project_id.to_string());
|
||||||
|
tracing::debug!(
|
||||||
|
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ws_ticket(ticket: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(ticket.as_bytes());
|
||||||
|
hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_can_access_project(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
project_id: uuid::Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"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?;
|
||||||
|
|
||||||
|
Ok(access.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume_ws_ticket(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
ticket: &str,
|
||||||
|
requested_project_id: Option<&str>,
|
||||||
|
) -> Result<uuid::Uuid, axum::http::StatusCode> {
|
||||||
|
let token_hash = hash_ws_ticket(ticket);
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"UPDATE websocket_tickets
|
||||||
|
SET used_at = NOW()
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > NOW()
|
||||||
|
RETURNING project_id"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?
|
||||||
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let project_id: uuid::Uuid = row.get("project_id");
|
||||||
|
|
||||||
|
if let Some(requested_project_id) = requested_project_id {
|
||||||
|
let requested = uuid::Uuid::parse_str(requested_project_id)
|
||||||
|
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
if requested != project_id {
|
||||||
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
||||||
|
filter_project_id
|
||||||
|
);
|
||||||
|
let mut rx = state.tx.subscribe();
|
||||||
|
|
||||||
|
// Ping interval para detectar conexiones zombi
|
||||||
|
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||||
|
ping_interval.tick().await; // consume el tick inmediato
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Branch 1: Recibir evento del broadcast y enviarlo al cliente
|
||||||
|
result = rx.recv() => {
|
||||||
|
match result {
|
||||||
|
Ok(event) => {
|
||||||
|
if let Some(ref target_id) = filter_project_id {
|
||||||
|
// target_id debe normalizarse una vez fuera del loop para máxima velocidad
|
||||||
|
if &*event.project_id != target_id.to_lowercase() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
|
||||||
|
tracing::debug!("WebSocket client disconnected (send failed)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
tracing::warn!("WebSocket receiver lagged by {} messages", count);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
tracing::error!("Broadcast channel closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Branch 2: Leer mensajes del cliente (detectar Close/Pong/desconexión)
|
||||||
|
msg = socket.recv() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
|
tracing::debug!("WebSocket client closed connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Pong(_))) => {
|
||||||
|
// Cliente respondió al ping, la conexión está viva
|
||||||
|
}
|
||||||
|
Some(Err(_)) => {
|
||||||
|
tracing::debug!("WebSocket read error, dropping connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {} // Ignorar otros mensajes (Text, Binary del cliente)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Branch 3: Enviar ping periódico para detectar zombis
|
||||||
|
_ = ping_interval.tick() => {
|
||||||
|
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
|
||||||
|
tracing::debug!("WebSocket ping failed, client is dead");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 WebSocket connection closed for project: {:?}",
|
||||||
|
filter_project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_ticket_hash_never_stores_raw_ticket() {
|
||||||
|
let ticket = "wst_example-ticket";
|
||||||
|
let hash = hash_ws_ticket(ticket);
|
||||||
|
|
||||||
|
assert_ne!(hash, ticket);
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
|
||||||
|
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
|
||||||
|
|
||||||
|
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
|
||||||
|
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
|
||||||
|
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
|
||||||
|
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ pub fn register_metrics() {
|
|||||||
);
|
);
|
||||||
describe_gauge!("omnioil_active_projects", "Number of active edge projects");
|
describe_gauge!("omnioil_active_projects", "Number of active edge projects");
|
||||||
describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)");
|
describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)");
|
||||||
describe_gauge!(
|
|
||||||
"omnioil_db_pool_size",
|
|
||||||
"Current number of database connections in the pool"
|
|
||||||
);
|
|
||||||
describe_gauge!(
|
|
||||||
"omnioil_db_pool_idle",
|
|
||||||
"Current number of idle database connections"
|
|
||||||
);
|
|
||||||
describe_counter!(
|
describe_counter!(
|
||||||
"omnioil_telemetry_messages_total",
|
"omnioil_telemetry_messages_total",
|
||||||
"Total telemetry MQTT messages processed"
|
"Total telemetry MQTT messages processed"
|
||||||
@@ -45,14 +37,6 @@ pub fn record_telemetry_received() {
|
|||||||
counter!("omnioil_telemetry_messages_total").increment(1);
|
counter!("omnioil_telemetry_messages_total").increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_active_agents(count: f64) {
|
|
||||||
gauge!("omnioil_active_agents").set(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_mqtt_connection() {
|
|
||||||
counter!("omnioil_mqtt_connections_total").increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_alarm_triggered() {
|
pub fn record_alarm_triggered() {
|
||||||
counter!("omnioil_alarms_triggered_total").increment(1);
|
counter!("omnioil_alarms_triggered_total").increment(1);
|
||||||
}
|
}
|
||||||
@@ -83,20 +67,4 @@ pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
|||||||
{
|
{
|
||||||
gauge!("omnioil_active_tags").set(count as f64);
|
gauge!("omnioil_active_tags").set(count as f64);
|
||||||
}
|
}
|
||||||
if let Ok(count) = sqlx::query_scalar::<_, i64>(
|
|
||||||
r#"SELECT COUNT(*) FROM edge_projects p
|
|
||||||
WHERE p.is_active = true
|
|
||||||
AND p.installer_status = 'COMPLETED'
|
|
||||||
AND p.last_health_report ? 'timestamp'
|
|
||||||
AND (p.last_health_report->>'timestamp')::TIMESTAMPTZ > NOW() - INTERVAL '5 minutes'"#,
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
gauge!("omnioil_active_agents").set(count as f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pool metrics
|
|
||||||
gauge!("omnioil_db_pool_size").set(pool.size() as f64);
|
|
||||||
gauge!("omnioil_db_pool_idle").set(pool.num_idle() as f64);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
TelemetryEvent, metrics, notifier,
|
|
||||||
};
|
|
||||||
use rumqttc::{AsyncClient, Event, Packet};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
/// Procesa mensajes MQTT de `omnioil/#` y `provision/request/+`.
|
|
||||||
///
|
|
||||||
/// Responsabilidades únicas de backend-api (no delegadas a telemetry-ingestor):
|
|
||||||
/// - `omnioil/status/`: estado LWT/reconexión → DB + WS
|
|
||||||
/// - `omnioil/alarms/`: dispatch de notificaciones (email/SMS) + WS relay
|
|
||||||
/// - `omnioil/opcua-scan/`: resultados de scan OPC UA → DB
|
|
||||||
/// - `omnioil/*` catch-all: relay genérico a WebSocket (real-time UI)
|
|
||||||
/// - `provision/request/+`: provisioning de agentes
|
|
||||||
///
|
|
||||||
/// Topics duplicados con telemetry-ingestor (solo WS relay, sin DB):
|
|
||||||
/// - `omnioil/health/`, `omnioil/logs/`, `omnioil/telemetry/`
|
|
||||||
pub async fn run_event_loop(
|
|
||||||
mut eventloop: rumqttc::EventLoop,
|
|
||||||
mqtt_client: AsyncClient,
|
|
||||||
pool: PgPool,
|
|
||||||
tx: broadcast::Sender<TelemetryEvent>,
|
|
||||||
) {
|
|
||||||
let tx_relay = tx.clone();
|
|
||||||
let prov_pool = pool.clone();
|
|
||||||
let prov_mqtt_client = mqtt_client.clone();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match eventloop.poll().await {
|
|
||||||
Ok(notification) => {
|
|
||||||
match notification {
|
|
||||||
Event::Incoming(Packet::Publish(publish)) => {
|
|
||||||
let topic = publish.topic.as_str();
|
|
||||||
|
|
||||||
if topic.starts_with("omnioil/status/") {
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/status/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(msg) =
|
|
||||||
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let status =
|
|
||||||
msg["status"].as_str().unwrap_or("offline").to_string();
|
|
||||||
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
|
|
||||||
{
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE edge_projects
|
|
||||||
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|
|
||||||
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
|
|
||||||
WHERE id = $2"#,
|
|
||||||
)
|
|
||||||
.bind(&status)
|
|
||||||
.bind(project_uuid)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let event = TelemetryEvent {
|
|
||||||
project_id: Arc::from(project_id_str.to_lowercase()),
|
|
||||||
payload: Arc::from(
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "agent_status",
|
|
||||||
"status": status,
|
|
||||||
"timestamp": msg["timestamp"]
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let _ = tx_ref.send(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/alarms/") {
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/alarms/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(event) = serde_json::from_slice::<
|
|
||||||
shared_lib::mqtt_messages::AlarmEvent,
|
|
||||||
>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let alarm = notifier::AlarmNotification {
|
|
||||||
project_id: event.project_id,
|
|
||||||
asset_id: None,
|
|
||||||
variable: event
|
|
||||||
.variable_alias
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
value: event.current_value.unwrap_or(0.0),
|
|
||||||
alarm_type: format!("{:?}", event.alarm_type),
|
|
||||||
message: event.message.unwrap_or_default(),
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
};
|
|
||||||
// Dispatch notificaciones (email/SMS) — única responsabilidad de backend-api
|
|
||||||
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
|
|
||||||
project_id_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/opcua-scan/") {
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(result) =
|
|
||||||
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let scan_id_str = result
|
|
||||||
.get("scan_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let status = result
|
|
||||||
.get("status")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("failed");
|
|
||||||
let error_msg = result.get("error").and_then(|v| v.as_str());
|
|
||||||
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE opcua_scan_results
|
|
||||||
SET status = $1, result = $2, error_message = $3
|
|
||||||
WHERE id = $4"#,
|
|
||||||
)
|
|
||||||
.bind(status)
|
|
||||||
.bind(&result)
|
|
||||||
.bind(error_msg)
|
|
||||||
.bind(scan_id)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
tracing::info!(
|
|
||||||
"✅ OPC UA scan result stored: {} ({})",
|
|
||||||
scan_id,
|
|
||||||
status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/") {
|
|
||||||
// Relay genérico: telemetría, alarmas, deploys, health, logs → WebSocket
|
|
||||||
let topic_parts: Vec<&str> = topic.split('/').collect();
|
|
||||||
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
|
|
||||||
|
|
||||||
metrics::record_telemetry_received();
|
|
||||||
let decompressed =
|
|
||||||
shared_lib::compression::decompress_if_needed(&publish.payload);
|
|
||||||
|
|
||||||
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
|
|
||||||
let event = TelemetryEvent {
|
|
||||||
project_id: Arc::from(project_id.to_lowercase()),
|
|
||||||
payload: Arc::from(payload_str),
|
|
||||||
};
|
|
||||||
match tx_relay.send(event) {
|
|
||||||
Ok(receivers) => {
|
|
||||||
tracing::debug!(
|
|
||||||
"📥 MQTT relayed to {} WS listeners",
|
|
||||||
receivers
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("⚠️ No WS listeners: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if topic.starts_with("provision/request/") {
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("provision/request/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let client_ref = prov_mqtt_client.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = crate::handlers::provisioning::handle_mqtt_provisioning(
|
|
||||||
&pool_ref,
|
|
||||||
&client_ref,
|
|
||||||
&project_id_str,
|
|
||||||
&payload_bytes,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"❌ Error en provisioning MQTT para proyecto {}: {}",
|
|
||||||
project_id_str,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Incoming(Packet::ConnAck(_)) => {
|
|
||||||
metrics::record_mqtt_connection();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("MQTT connection error: {:?}", e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
253
services/backend-api/src/mqtt_telemetry.rs
Normal file
253
services/backend-api/src/mqtt_telemetry.rs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::TelemetryEvent;
|
||||||
|
|
||||||
|
pub type DeviceResolverCache = Arc<RwLock<HashMap<DeviceResolverKey, DeviceResolution>>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DeviceResolverKey {
|
||||||
|
pub project_id: Uuid,
|
||||||
|
pub device_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum DeviceResolution {
|
||||||
|
Resolved(Uuid),
|
||||||
|
Unresolved,
|
||||||
|
Ambiguous,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EdgeTelemetryPayload {
|
||||||
|
pub project_id: Option<Uuid>,
|
||||||
|
pub device_name: String,
|
||||||
|
pub values: serde_json::Value,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_device_resolver_cache() -> DeviceResolverCache {
|
||||||
|
Arc::new(RwLock::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_edge_telemetry(
|
||||||
|
pool: &PgPool,
|
||||||
|
tx: &broadcast::Sender<TelemetryEvent>,
|
||||||
|
cache: &DeviceResolverCache,
|
||||||
|
topic_project_id: Uuid,
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let decompressed = shared_lib::compression::decompress_if_needed(payload_bytes);
|
||||||
|
let payload: EdgeTelemetryPayload = serde_json::from_slice(decompressed.as_ref())?;
|
||||||
|
|
||||||
|
crate::metrics::record_telemetry_received();
|
||||||
|
|
||||||
|
if let Some(payload_project_id) = payload.project_id {
|
||||||
|
if !payload_project_matches_topic(Some(payload_project_id), topic_project_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
topic_project_id = %topic_project_id,
|
||||||
|
payload_project_id = %payload_project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: payload project_id does not match authoritative topic project_id"
|
||||||
|
);
|
||||||
|
crate::metrics::record_telemetry_project_mismatch();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolution = resolve_device(pool, cache, topic_project_id, &payload.device_name).await?;
|
||||||
|
let asset_id = match resolution {
|
||||||
|
DeviceResolution::Resolved(asset_id) => asset_id,
|
||||||
|
DeviceResolution::Unresolved => {
|
||||||
|
tracing::warn!(
|
||||||
|
project_id = %topic_project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: device could not be resolved"
|
||||||
|
);
|
||||||
|
crate::metrics::record_telemetry_unresolved_device();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
DeviceResolution::Ambiguous => {
|
||||||
|
tracing::warn!(
|
||||||
|
project_id = %topic_project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: device resolution is ambiguous"
|
||||||
|
);
|
||||||
|
crate::metrics::record_telemetry_ambiguous_device();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(insert_scada_telemetry_sql())
|
||||||
|
.bind(payload.timestamp)
|
||||||
|
.bind(topic_project_id)
|
||||||
|
.bind(asset_id)
|
||||||
|
.bind(&payload.values)
|
||||||
|
.bind(&payload.device_name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ws_payload = serde_json::json!({
|
||||||
|
"type": "telemetry",
|
||||||
|
"project_id": topic_project_id,
|
||||||
|
"asset_id": asset_id,
|
||||||
|
"device_name": payload.device_name,
|
||||||
|
"source": "SCADA",
|
||||||
|
"frequency_class": "HF",
|
||||||
|
"timestamp": payload.timestamp,
|
||||||
|
"values": payload.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = tx.send(TelemetryEvent {
|
||||||
|
project_id: Arc::from(topic_project_id.to_string()),
|
||||||
|
payload: Arc::from(ws_payload.to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn payload_project_matches_topic(
|
||||||
|
payload_project_id: Option<Uuid>,
|
||||||
|
topic_project_id: Uuid,
|
||||||
|
) -> bool {
|
||||||
|
match payload_project_id {
|
||||||
|
Some(payload_project_id) => payload_project_id == topic_project_id,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn telemetry_project_id_from_topic(topic: &str) -> Option<Uuid> {
|
||||||
|
topic
|
||||||
|
.strip_prefix("omnioil/telemetry/")
|
||||||
|
.and_then(|tail| tail.split('/').next())
|
||||||
|
.filter(|project_id| !project_id.is_empty())
|
||||||
|
.and_then(|project_id| Uuid::parse_str(project_id).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_device(
|
||||||
|
pool: &PgPool,
|
||||||
|
cache: &DeviceResolverCache,
|
||||||
|
project_id: Uuid,
|
||||||
|
device_name: &str,
|
||||||
|
) -> anyhow::Result<DeviceResolution> {
|
||||||
|
let key = DeviceResolverKey {
|
||||||
|
project_id,
|
||||||
|
device_name: device_name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(cached) = cache.read().await.get(&key).cloned() {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(resolve_device_sql())
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(device_name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let resolution = match rows.len() {
|
||||||
|
0 => DeviceResolution::Unresolved,
|
||||||
|
1 => DeviceResolution::Resolved(rows[0].get("asset_id")),
|
||||||
|
_ => DeviceResolution::Ambiguous,
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(resolution, DeviceResolution::Resolved(_)) {
|
||||||
|
cache.write().await.insert(key, resolution.clone());
|
||||||
|
}
|
||||||
|
Ok(resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_device_sql() -> &'static str {
|
||||||
|
r#"SELECT d.asset_id
|
||||||
|
FROM edge_devices d
|
||||||
|
JOIN edge_assets a ON d.asset_id = a.id
|
||||||
|
WHERE a.project_id = $1
|
||||||
|
AND d.name = $2"#
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_scada_telemetry_sql() -> &'static str {
|
||||||
|
r#"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)
|
||||||
|
VALUES ($1, $2, $3, $4, 'SCADA', $5)
|
||||||
|
ON CONFLICT (time, asset_id) DO NOTHING"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_uses_strict_project_device_join_without_schema_shortcut() {
|
||||||
|
let sql = resolve_device_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("FROM edge_devices d"));
|
||||||
|
assert!(sql.contains("JOIN edge_assets a ON d.asset_id = a.id"));
|
||||||
|
assert!(sql.contains("a.project_id = $1"));
|
||||||
|
assert!(sql.contains("d.name = $2"));
|
||||||
|
assert!(!sql.contains("d.project_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scada_insert_never_allows_null_asset_and_persists_source_client() {
|
||||||
|
let sql = insert_scada_telemetry_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains(
|
||||||
|
"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)"
|
||||||
|
));
|
||||||
|
assert!(sql.contains("VALUES ($1, $2, $3, $4, 'SCADA', $5)"));
|
||||||
|
assert!(sql.contains("ON CONFLICT (time, asset_id) DO NOTHING"));
|
||||||
|
assert!(!sql.contains("NULL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_topic_project_id_parser_accepts_only_valid_telemetry_topics() {
|
||||||
|
let project_id = Uuid::new_v4();
|
||||||
|
let topic = format!("omnioil/telemetry/{project_id}");
|
||||||
|
|
||||||
|
assert_eq!(telemetry_project_id_from_topic(&topic), Some(project_id));
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_project_id_from_topic(&format!("omnioil/telemetry/{project_id}/extra")),
|
||||||
|
Some(project_id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_project_id_from_topic("omnioil/telemetry/not-a-uuid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_project_id_from_topic("omnioil/status/not-a-uuid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edge_payload_accepts_missing_legacy_project_id() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"device_name": "pump-1",
|
||||||
|
"values": { "pressure": 10 },
|
||||||
|
"timestamp": "2026-06-27T00:00:00Z"
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed: EdgeTelemetryPayload = serde_json::from_value(payload).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.project_id, None);
|
||||||
|
assert_eq!(parsed.device_name, "pump-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_project_id_must_match_authoritative_topic_when_present() {
|
||||||
|
let topic_project_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
assert!(payload_project_matches_topic(None, topic_project_id));
|
||||||
|
assert!(payload_project_matches_topic(
|
||||||
|
Some(topic_project_id),
|
||||||
|
topic_project_id
|
||||||
|
));
|
||||||
|
assert!(!payload_project_matches_topic(
|
||||||
|
Some(Uuid::new_v4()),
|
||||||
|
topic_project_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,10 +173,11 @@ async fn send_telegram_alarm(
|
|||||||
destination: &str,
|
destination: &str,
|
||||||
alarm: &AlarmNotification,
|
alarm: &AlarmNotification,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
|
let token = std::env::var("TELEGRAM_BOT_TOKEN")
|
||||||
|
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
|
||||||
|
|
||||||
let chat_id = if destination.trim().is_empty() {
|
let chat_id = if destination.trim().is_empty() {
|
||||||
std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default()
|
std::env::var("TELEGRAM_CHAT_ID").unwrap_or_else(|_| "-4336874579".to_string())
|
||||||
} else {
|
} else {
|
||||||
destination.to_string()
|
destination.to_string()
|
||||||
};
|
};
|
||||||
@@ -194,12 +195,7 @@ async fn send_telegram_alarm(
|
|||||||
• *Tipo:* {}\n\
|
• *Tipo:* {}\n\
|
||||||
• *Mensaje:* {}\n\
|
• *Mensaje:* {}\n\
|
||||||
• *Timestamp:* {}",
|
• *Timestamp:* {}",
|
||||||
alarm.variable,
|
alarm.variable, alarm.variable, alarm.value, alarm.alarm_type, alarm.message, alarm.timestamp
|
||||||
alarm.variable,
|
|
||||||
alarm.value,
|
|
||||||
alarm.alarm_type,
|
|
||||||
alarm.message,
|
|
||||||
alarm.timestamp
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
@@ -274,15 +270,15 @@ pub async fn send_system_alert(context: &str, error: &anyhow::Error) {
|
|||||||
tracing::error!("Fallo al enviar alerta de sistema por correo: {}", e);
|
tracing::error!("Fallo al enviar alerta de sistema por correo: {}", e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!("No se pudo enviar alerta de sistema por correo: ADMIN_EMAIL, SMTP_FROM y SMTP_USER no están configurados");
|
||||||
"No se pudo enviar alerta de sistema por correo: ADMIN_EMAIL, SMTP_FROM y SMTP_USER no están configurados"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_telegram_direct(message: &str) -> anyhow::Result<()> {
|
async fn send_telegram_direct(message: &str) -> anyhow::Result<()> {
|
||||||
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
|
let token = std::env::var("TELEGRAM_BOT_TOKEN")
|
||||||
let chat_id = std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default();
|
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
|
||||||
|
let chat_id = std::env::var("TELEGRAM_CHAT_ID")
|
||||||
|
.unwrap_or_else(|_| "-4336874579".to_string());
|
||||||
|
|
||||||
if token.is_empty() || chat_id.is_empty() {
|
if token.is_empty() || chat_id.is_empty() {
|
||||||
tracing::warn!("Telegram no configurado: TOKEN o CHAT_ID faltantes");
|
tracing::warn!("Telegram no configurado: TOKEN o CHAT_ID faltantes");
|
||||||
@@ -331,10 +327,7 @@ mod tests {
|
|||||||
let first_instant = {
|
let first_instant = {
|
||||||
let map = map_mutex.lock().unwrap();
|
let map = map_mutex.lock().unwrap();
|
||||||
let inst = map.get(context).cloned();
|
let inst = map.get(context).cloned();
|
||||||
assert!(
|
assert!(inst.is_some(), "Debería haberse registrado el envío de la alerta");
|
||||||
inst.is_some(),
|
|
||||||
"Debería haberse registrado el envío de la alerta"
|
|
||||||
);
|
|
||||||
inst.unwrap()
|
inst.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -344,19 +337,13 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let map = map_mutex.lock().unwrap();
|
let map = map_mutex.lock().unwrap();
|
||||||
let second_instant = map.get(context).cloned().unwrap();
|
let second_instant = map.get(context).cloned().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(first_instant, second_instant, "El segundo envío inmediato no debería actualizar el timestamp debido al cooldown");
|
||||||
first_instant, second_instant,
|
|
||||||
"El segundo envío inmediato no debería actualizar el timestamp debido al cooldown"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simular que el cooldown ya pasó (retrocediendo el tiempo de envío en el mapa)
|
// Simular que el cooldown ya pasó (retrocediendo el tiempo de envío en el mapa)
|
||||||
{
|
{
|
||||||
let mut map = map_mutex.lock().unwrap();
|
let mut map = map_mutex.lock().unwrap();
|
||||||
map.insert(
|
map.insert(context.to_string(), Instant::now() - Duration::from_secs(650));
|
||||||
context.to_string(),
|
|
||||||
Instant::now() - Duration::from_secs(650),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tercer envío después del cooldown (debería registrarse y actualizar el timestamp)
|
// Tercer envío después del cooldown (debería registrarse y actualizar el timestamp)
|
||||||
@@ -365,10 +352,7 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let map = map_mutex.lock().unwrap();
|
let map = map_mutex.lock().unwrap();
|
||||||
let third_instant = map.get(context).cloned().unwrap();
|
let third_instant = map.get(context).cloned().unwrap();
|
||||||
assert!(
|
assert!(third_instant > first_instant, "El envío después del cooldown debería actualizar el timestamp");
|
||||||
third_instant > first_instant,
|
|
||||||
"El envío después del cooldown debería actualizar el timestamp"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Query, State, ws::{Message, WebSocket, WebSocketUpgrade}},
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::{get, post},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use crate::{
|
|
||||||
auth, AppState,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use sqlx::Row;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(crate) struct WsParams {
|
|
||||||
project_id: Option<String>,
|
|
||||||
ticket: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(crate) struct CreateWsTicketRequest {
|
|
||||||
project_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub(crate) struct CreateWsTicketResponse {
|
|
||||||
ticket: String,
|
|
||||||
expires_in: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
const WS_TICKET_TTL_SECONDS: u64 = 60;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
|
|
||||||
.route("/api/ws/telemetry", get(ws_telemetry_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_ws_telemetry_ticket(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
claims: auth::Claims,
|
|
||||||
Json(req): Json<CreateWsTicketRequest>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
|
|
||||||
Ok(user_id) => user_id,
|
|
||||||
Err(_) => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
|
||||||
Json(serde_json::json!({"error": "Usuario inválido"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
|
|
||||||
Ok(project_id) => project_id,
|
|
||||||
Err(_) => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({"error": "project_id inválido"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match user_can_access_project(&state.pool, user_id, project_id).await {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::FORBIDDEN,
|
|
||||||
Json(serde_json::json!({"error": "Access denied to this project"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(error = %error, "Failed to check WebSocket project access");
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(serde_json::json!({"error": "Ticket creation failed"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
|
|
||||||
let token_hash = hash_ws_ticket(&ticket);
|
|
||||||
let expires_in = WS_TICKET_TTL_SECONDS as i64;
|
|
||||||
|
|
||||||
if let Err(error) = sqlx::query(
|
|
||||||
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
|
|
||||||
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
|
|
||||||
)
|
|
||||||
.bind(token_hash)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.bind(format!("{} seconds", expires_in))
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(serde_json::json!({"error": "Ticket creation failed"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
Json(CreateWsTicketResponse {
|
|
||||||
ticket,
|
|
||||||
expires_in: WS_TICKET_TTL_SECONDS,
|
|
||||||
})
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ws_telemetry_handler(
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
query: Query<WsParams>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let ticket = match &query.ticket {
|
|
||||||
Some(ticket) => ticket.as_str(),
|
|
||||||
None => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
|
||||||
Json(serde_json::json!({"error": "Ticket requerido para conexión WebSocket"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let project_id = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
|
|
||||||
{
|
|
||||||
Ok(project_id) => project_id,
|
|
||||||
Err(status) => {
|
|
||||||
let message = if status == axum::http::StatusCode::FORBIDDEN {
|
|
||||||
"Ticket no corresponde al proyecto solicitado"
|
|
||||||
} else {
|
|
||||||
"Ticket inválido o expirado"
|
|
||||||
};
|
|
||||||
return (status, Json(serde_json::json!({"error": message}))).into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let project_id = Some(project_id.to_string());
|
|
||||||
tracing::debug!(
|
|
||||||
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
|
||||||
project_id
|
|
||||||
);
|
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_ws_ticket(ticket: &str) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(ticket.as_bytes());
|
|
||||||
hasher
|
|
||||||
.finalize()
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn user_can_access_project(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
project_id: uuid::Uuid,
|
|
||||||
) -> Result<bool, sqlx::Error> {
|
|
||||||
let access = sqlx::query(
|
|
||||||
r#"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?;
|
|
||||||
|
|
||||||
Ok(access.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn consume_ws_ticket(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
ticket: &str,
|
|
||||||
requested_project_id: Option<&str>,
|
|
||||||
) -> Result<uuid::Uuid, axum::http::StatusCode> {
|
|
||||||
let token_hash = hash_ws_ticket(ticket);
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"UPDATE websocket_tickets
|
|
||||||
SET used_at = NOW()
|
|
||||||
WHERE token_hash = $1
|
|
||||||
AND used_at IS NULL
|
|
||||||
AND expires_at > NOW()
|
|
||||||
RETURNING project_id"#,
|
|
||||||
)
|
|
||||||
.bind(token_hash)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?
|
|
||||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
let project_id: uuid::Uuid = row.get("project_id");
|
|
||||||
|
|
||||||
if let Some(requested_project_id) = requested_project_id {
|
|
||||||
let requested = uuid::Uuid::parse_str(requested_project_id)
|
|
||||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
|
||||||
if requested != project_id {
|
|
||||||
return Err(axum::http::StatusCode::FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(project_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
|
||||||
tracing::info!(
|
|
||||||
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
|
||||||
filter_project_id
|
|
||||||
);
|
|
||||||
let mut rx = state.tx.subscribe();
|
|
||||||
|
|
||||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
|
||||||
ping_interval.tick().await;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
result = rx.recv() => {
|
|
||||||
match result {
|
|
||||||
Ok(event) => {
|
|
||||||
if let Some(ref target_id) = filter_project_id {
|
|
||||||
if &*event.project_id != target_id.to_lowercase() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
|
|
||||||
tracing::debug!("WebSocket client disconnected (send failed)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
|
||||||
tracing::warn!("WebSocket receiver lagged by {} messages", count);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
||||||
tracing::error!("Broadcast channel closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg = socket.recv() => {
|
|
||||||
match msg {
|
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
|
||||||
tracing::debug!("WebSocket client closed connection");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Pong(_))) => {}
|
|
||||||
Some(Err(_)) => {
|
|
||||||
tracing::debug!("WebSocket read error, dropping connection");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = ping_interval.tick() => {
|
|
||||||
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
|
|
||||||
tracing::debug!("WebSocket ping failed, client is dead");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
"🔌 WebSocket connection closed for project: {:?}",
|
|
||||||
filter_project_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn websocket_ticket_hash_never_stores_raw_ticket() {
|
|
||||||
let ticket = "wst_example-ticket";
|
|
||||||
let hash = hash_ws_ticket(ticket);
|
|
||||||
|
|
||||||
assert_ne!(hash, ticket);
|
|
||||||
assert_eq!(hash.len(), 64);
|
|
||||||
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
|
|
||||||
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
|
|
||||||
|
|
||||||
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
|
|
||||||
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
|
|
||||||
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
|
|
||||||
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use backend_api::billing_rules::{
|
|
||||||
DEFAULT_OPERATIVE_K, DEFAULT_OPERATIVE_PBASE_COP, OperativeBillingInput,
|
|
||||||
calculate_operative_billing, calculate_operative_billing_with_defaults,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn expected_annual_cop(equivalent_tg: f64, pbase_cop: i64, k: f64) -> i64 {
|
|
||||||
(equivalent_tg * pbase_cop as f64 * equivalent_tg.powf(-k)).round() as i64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hf_counts_as_one_equivalent_tg_with_defaults() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(1, 0).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.hf_tags, 1);
|
|
||||||
assert_eq!(result.lf_tags, 0);
|
|
||||||
assert_eq!(result.equivalent_tg, 1.0);
|
|
||||||
assert_eq!(result.pbase_cop, DEFAULT_OPERATIVE_PBASE_COP);
|
|
||||||
assert_eq!(result.k, DEFAULT_OPERATIVE_K);
|
|
||||||
assert_eq!(result.annual_cop, 110_000);
|
|
||||||
assert_eq!(result.monthly_cop, 9_167);
|
|
||||||
assert_eq!(result.effective_hf_price_cop, 110_000);
|
|
||||||
assert_eq!(result.effective_lf_price_cop, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lf_counts_as_zero_point_four_equivalent_tg() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(0, 1).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.hf_tags, 0);
|
|
||||||
assert_eq!(result.lf_tags, 1);
|
|
||||||
assert_eq!(result.equivalent_tg, 0.4);
|
|
||||||
assert_eq!(
|
|
||||||
result.annual_cop,
|
|
||||||
expected_annual_cop(0.4, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
|
|
||||||
);
|
|
||||||
assert_eq!(result.effective_hf_price_cop, 0);
|
|
||||||
assert_eq!(result.effective_lf_price_cop, result.annual_cop);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mixed_usage_exposes_equivalent_tg_and_not_flat_total_tags_pricing() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(1, 1).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.equivalent_tg, 1.4);
|
|
||||||
assert_eq!(
|
|
||||||
result.annual_cop,
|
|
||||||
expected_annual_cop(1.4, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
|
|
||||||
);
|
|
||||||
assert_ne!(result.annual_cop, 2 * DEFAULT_OPERATIVE_PBASE_COP);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn volume_discount_uses_v_to_the_negative_k_factor() {
|
|
||||||
let one_tg = calculate_operative_billing_with_defaults(1, 0).expect("valid usage");
|
|
||||||
let ten_tg = calculate_operative_billing_with_defaults(10, 0).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(ten_tg.equivalent_tg, 10.0);
|
|
||||||
assert_eq!(
|
|
||||||
ten_tg.annual_cop,
|
|
||||||
expected_annual_cop(10.0, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
|
|
||||||
);
|
|
||||||
assert!(ten_tg.effective_hf_price_cop < one_tg.effective_hf_price_cop);
|
|
||||||
assert_ne!(ten_tg.annual_cop, 10 * DEFAULT_OPERATIVE_PBASE_COP);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_usage_returns_zero_without_applying_power_formula() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(0, 0).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.equivalent_tg, 0.0);
|
|
||||||
assert_eq!(result.annual_cop, 0);
|
|
||||||
assert_eq!(result.monthly_cop, 0);
|
|
||||||
assert_eq!(result.effective_hf_price_cop, 0);
|
|
||||||
assert_eq!(result.effective_lf_price_cop, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_defaults_drive_effective_prices_until_profile_values_are_stored() {
|
|
||||||
let result = calculate_operative_billing(OperativeBillingInput {
|
|
||||||
hf_tags: 2,
|
|
||||||
lf_tags: 5,
|
|
||||||
pbase_cop: 200_000,
|
|
||||||
k: 0.20,
|
|
||||||
})
|
|
||||||
.expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.equivalent_tg, 4.0);
|
|
||||||
assert_eq!(result.annual_cop, expected_annual_cop(4.0, 200_000, 0.20));
|
|
||||||
assert_eq!(
|
|
||||||
result.effective_hf_price_cop,
|
|
||||||
(result.annual_cop as f64 / result.equivalent_tg).round() as i64
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.effective_lf_price_cop,
|
|
||||||
((result.annual_cop as f64 / result.equivalent_tg) * 0.4).round() as i64
|
|
||||||
);
|
|
||||||
assert_eq!(result.currency, "COP");
|
|
||||||
assert_eq!(result.rounding_policy, "nearest peso");
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,3 @@ hex = "0.4.3"
|
|||||||
aws-config = "1.5"
|
aws-config = "1.5"
|
||||||
aws-sdk-s3 = "1.50"
|
aws-sdk-s3 = "1.50"
|
||||||
reqwest = { workspace = true, features = ["json", "rustls", "stream"] }
|
reqwest = { workspace = true, features = ["json", "rustls", "stream"] }
|
||||||
axum = { workspace = true }
|
|
||||||
tower = { workspace = true }
|
|
||||||
prometheus = { workspace = true }
|
|
||||||
|
|||||||
@@ -558,10 +558,7 @@ pub async fn handle_project_created(
|
|||||||
pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let binary_path = std::path::Path::new("target/x86_64-unknown-linux-musl/release/edge-agent");
|
let binary_path = std::path::Path::new("target/x86_64-unknown-linux-musl/release/edge-agent");
|
||||||
if !binary_path.exists() {
|
if !binary_path.exists() {
|
||||||
tracing::warn!(
|
tracing::warn!("⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}", binary_path);
|
||||||
"⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}",
|
|
||||||
binary_path
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,12 +624,7 @@ pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Err
|
|||||||
let body = aws_sdk_s3::primitives::ByteStream::from_path(binary_path).await?;
|
let body = aws_sdk_s3::primitives::ByteStream::from_path(binary_path).await?;
|
||||||
let key = "agents/linux/x86_64/edge-agent".to_string();
|
let key = "agents/linux/x86_64/edge-agent".to_string();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!("🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...", version, bucket_name, key);
|
||||||
"🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...",
|
|
||||||
version,
|
|
||||||
bucket_name,
|
|
||||||
key
|
|
||||||
);
|
|
||||||
client
|
client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(&bucket_name)
|
.bucket(&bucket_name)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
mod installer_worker;
|
mod installer_worker;
|
||||||
mod metrics;
|
|
||||||
mod publisher;
|
mod publisher;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -7,8 +6,6 @@ use uuid::Uuid;
|
|||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created";
|
const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created";
|
||||||
@@ -128,40 +125,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "info,build_orchestrator=debug".into()),
|
.unwrap_or_else(|_| "info,build_orchestrator=debug".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer().json().flatten_event(false))
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// Metrics HTTP server
|
|
||||||
let metrics_app = axum::Router::new().route(
|
|
||||||
"/metrics",
|
|
||||||
axum::routing::get(|| async {
|
|
||||||
use prometheus::TextEncoder;
|
|
||||||
let encoder = TextEncoder::new();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
encoder
|
|
||||||
.encode_utf8(&prometheus::gather(), &mut buffer)
|
|
||||||
.unwrap();
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::OK,
|
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
|
||||||
buffer,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let metrics_addr: SocketAddr = ([0, 0, 0, 0], 9094).into();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match tokio::net::TcpListener::bind(metrics_addr).await {
|
|
||||||
Ok(listener) => {
|
|
||||||
if let Err(e) = axum::serve(listener, metrics_app).await {
|
|
||||||
tracing::error!("Metrics HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to bind metrics endpoint on {}: {}", metrics_addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)...");
|
tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)...");
|
||||||
|
|
||||||
// Conectar a la base de datos con retry — necesario si el orquestador
|
// Conectar a la base de datos con retry — necesario si el orquestador
|
||||||
@@ -172,36 +138,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
loop {
|
loop {
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
match PgPoolOptions::new()
|
match PgPoolOptions::new()
|
||||||
.max_connections(
|
.max_connections(5)
|
||||||
env::var("DB_MAX_CONNECTIONS")
|
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(5),
|
|
||||||
)
|
|
||||||
.min_connections(
|
|
||||||
env::var("DB_MIN_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)
|
|
||||||
.idle_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(600),
|
|
||||||
))
|
|
||||||
.max_lifetime(Duration::from_secs(
|
|
||||||
env::var("DB_MAX_LIFETIME_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1800),
|
|
||||||
))
|
|
||||||
.acquire_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_ACQUIRE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
))
|
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -268,7 +206,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let state = state_worker.clone();
|
let state = state_worker.clone();
|
||||||
|
|
||||||
if topic == PROJECT_CREATED_TOPIC {
|
if topic == PROJECT_CREATED_TOPIC {
|
||||||
metrics::BUILDS_TRIGGERED.inc();
|
|
||||||
let payload_str = String::from_utf8_lossy(&payload);
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
||||||
if let Ok(event) = serde_json::from_str::<
|
if let Ok(event) = serde_json::from_str::<
|
||||||
@@ -284,14 +221,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Worker de instalador falló: {}", e);
|
tracing::error!("Worker de instalador falló: {}", e);
|
||||||
metrics::BUILDS_FAILED.inc();
|
|
||||||
} else {
|
|
||||||
metrics::BUILDS_COMPLETED.inc();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if topic.starts_with("omnioil/control/deploy/") {
|
} else if topic.starts_with("omnioil/control/deploy/") {
|
||||||
metrics::DEPLOYS_TRIGGERED.inc();
|
|
||||||
let payload_str = String::from_utf8_lossy(&payload);
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📥 Recibido Deployment Request. Payload crudo: {}",
|
"📥 Recibido Deployment Request. Payload crudo: {}",
|
||||||
@@ -317,9 +250,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Pipeline de build falló: {}", e);
|
tracing::error!("Pipeline de build falló: {}", e);
|
||||||
metrics::BUILDS_FAILED.inc();
|
|
||||||
} else {
|
|
||||||
metrics::BUILDS_COMPLETED.inc();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
use prometheus::{register_counter, Counter};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
pub static BUILDS_TRIGGERED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_builds_triggered_total",
|
|
||||||
"Total installer builds triggered"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static BUILDS_COMPLETED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_builds_completed_total",
|
|
||||||
"Total installer builds completed successfully"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static BUILDS_FAILED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_builds_failed_total",
|
|
||||||
"Total installer builds that failed"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static DEPLOYS_TRIGGERED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_deploys_triggered_total",
|
|
||||||
"Total deployments triggered"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
@@ -51,11 +51,10 @@ winres = "0.1.12"
|
|||||||
# windows-service, winit, image: sólo se usan en Windows / edge-tray
|
# windows-service, winit, image: sólo se usan en Windows / edge-tray
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-service = "0.8.0"
|
windows-service = "0.8.0"
|
||||||
tray-icon = "0.24"
|
tray-icon = "0.22.0"
|
||||||
winit = "0.30.13"
|
winit = "0.30.13"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
windows-sys = { version = "0.61.2", features = [
|
windows-sys = { version = "0.59", features = [
|
||||||
"Win32_Security_Cryptography",
|
"Win32_Security_Cryptography",
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Security",
|
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ COPY --from=builder /app/bin/linux/edge-agent /app/services/edge-agent/target/x8
|
|||||||
|
|
||||||
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
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/*.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 sed -i 's/\r$//' /app/services/edge-agent/msi_worker.sh && \
|
RUN sed -i 's/\r$//' /app/services/edge-agent/msi_worker.sh && \
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 229 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -6,39 +6,32 @@
|
|||||||
|
|
||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
#[cfg(windows)]
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
#[path = "../ipc_protocol.rs"]
|
use tray_icon::{
|
||||||
mod ipc_protocol;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
mod app {
|
|
||||||
use super::ipc_protocol::{AgentCommand, IpcMessage};
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tray_icon::{
|
|
||||||
TrayIcon, TrayIconBuilder,
|
TrayIcon, TrayIconBuilder,
|
||||||
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem},
|
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem},
|
||||||
};
|
};
|
||||||
use winit::application::ApplicationHandler;
|
use winit::application::ApplicationHandler;
|
||||||
use winit::event_loop::ActiveEventLoop;
|
use winit::event_loop::ActiveEventLoop;
|
||||||
use winit::event_loop::{ControlFlow, EventLoop};
|
use winit::event_loop::{ControlFlow, EventLoop};
|
||||||
|
|
||||||
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
// Protocolo compartido
|
||||||
|
#[path = "../ipc_protocol.rs"]
|
||||||
|
mod ipc_protocol;
|
||||||
|
use ipc_protocol::{AgentCommand, IpcMessage};
|
||||||
|
|
||||||
struct EdgeTrayApp {
|
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
||||||
|
|
||||||
|
struct EdgeTrayApp {
|
||||||
tray_icon: Option<TrayIcon>,
|
tray_icon: Option<TrayIcon>,
|
||||||
quit_id: MenuId,
|
quit_id: MenuId,
|
||||||
test_id: MenuId,
|
test_id: MenuId,
|
||||||
status_label: MenuItem,
|
status_label: MenuItem,
|
||||||
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
|
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
|
||||||
status_rx: tokio::sync::mpsc::Receiver<IpcMessage>,
|
status_rx: tokio::sync::mpsc::Receiver<String>,
|
||||||
// Estados de animación y actualización
|
}
|
||||||
is_updating: bool,
|
|
||||||
animation_tick: usize,
|
|
||||||
last_tick_time: std::time::Instant,
|
|
||||||
connected_mqtt: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApplicationHandler for EdgeTrayApp {
|
impl ApplicationHandler for EdgeTrayApp {
|
||||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
||||||
|
|
||||||
fn window_event(
|
fn window_event(
|
||||||
@@ -51,96 +44,8 @@ mod app {
|
|||||||
|
|
||||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
// Recibir actualizaciones de estado del backend
|
// Recibir actualizaciones de estado del backend
|
||||||
while let Ok(msg) = self.status_rx.try_recv() {
|
while let Ok(new_status) = self.status_rx.try_recv() {
|
||||||
match msg {
|
self.status_label.set_text(new_status);
|
||||||
IpcMessage::StatusUpdate {
|
|
||||||
status,
|
|
||||||
connected_mqtt,
|
|
||||||
current_version,
|
|
||||||
} => {
|
|
||||||
self.connected_mqtt = connected_mqtt;
|
|
||||||
self.status_label
|
|
||||||
.set_text(format!("Estado: {} (v{})", status, current_version));
|
|
||||||
|
|
||||||
if !self.is_updating {
|
|
||||||
let icon_path =
|
|
||||||
std::env::current_exe().unwrap().parent().unwrap().join(
|
|
||||||
if connected_mqtt {
|
|
||||||
"icon_connected.ico"
|
|
||||||
} else {
|
|
||||||
"icon_disconnected.ico"
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let icon = load_icon_or_default(&icon_path, connected_mqtt);
|
|
||||||
if let Some(ref mut tray) = self.tray_icon {
|
|
||||||
let _ = tray.set_icon(Some(icon));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IpcMessage::OtaProgress {
|
|
||||||
percentage,
|
|
||||||
stage,
|
|
||||||
target_version,
|
|
||||||
error_message,
|
|
||||||
} => {
|
|
||||||
if stage == "Downloading" {
|
|
||||||
self.is_updating = true;
|
|
||||||
self.status_label.set_text(format!(
|
|
||||||
"Descargando v{} ({}%)",
|
|
||||||
target_version, percentage
|
|
||||||
));
|
|
||||||
} else if stage == "Verifying" {
|
|
||||||
self.is_updating = true;
|
|
||||||
self.status_label
|
|
||||||
.set_text(format!("Verificando v{}...", target_version));
|
|
||||||
} else if stage == "Applying" {
|
|
||||||
self.is_updating = true;
|
|
||||||
self.status_label
|
|
||||||
.set_text(format!("Instalando v{}...", target_version));
|
|
||||||
} else if stage == "Finished" {
|
|
||||||
self.is_updating = false;
|
|
||||||
self.status_label
|
|
||||||
.set_text(format!("¡v{} instalada con éxito!", target_version));
|
|
||||||
// Esperar la actualización de estado del agente para restaurar el icono
|
|
||||||
} else if stage == "Error" {
|
|
||||||
self.is_updating = false;
|
|
||||||
let err =
|
|
||||||
error_message.unwrap_or_else(|| "Error desconocido".to_string());
|
|
||||||
self.status_label
|
|
||||||
.set_text(format!("Fallo actualización: {}", err));
|
|
||||||
|
|
||||||
// Restaurar icono original
|
|
||||||
let icon_path =
|
|
||||||
std::env::current_exe().unwrap().parent().unwrap().join(
|
|
||||||
if self.connected_mqtt {
|
|
||||||
"icon_connected.ico"
|
|
||||||
} else {
|
|
||||||
"icon_disconnected.ico"
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let icon = load_icon_or_default(&icon_path, self.connected_mqtt);
|
|
||||||
if let Some(ref mut tray) = self.tray_icon {
|
|
||||||
let _ = tray.set_icon(Some(icon));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IpcMessage::Notification { title, message } => {
|
|
||||||
println!("[NOTIFICATION] {}: {}", title, message);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Realizar animación si la actualización está activa
|
|
||||||
if self.is_updating {
|
|
||||||
if self.last_tick_time.elapsed() >= std::time::Duration::from_millis(150) {
|
|
||||||
self.animation_tick += 1;
|
|
||||||
let animated_icon = generate_updating_icon(self.animation_tick);
|
|
||||||
if let Some(ref mut tray) = self.tray_icon {
|
|
||||||
let _ = tray.set_icon(Some(animated_icon));
|
|
||||||
}
|
|
||||||
self.last_tick_time = std::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revisar clicks en el menú
|
// Revisar clicks en el menú
|
||||||
@@ -155,9 +60,10 @@ mod app {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
let event_loop = EventLoop::new().unwrap();
|
let event_loop = EventLoop::new().unwrap();
|
||||||
event_loop.set_control_flow(ControlFlow::Wait);
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
|
||||||
@@ -184,22 +90,24 @@ mod app {
|
|||||||
.parent()
|
.parent()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.join("icon.ico");
|
.join("icon.ico");
|
||||||
let icon = load_icon_or_default(&icon_path, false);
|
let icon = load_icon(&icon_path);
|
||||||
|
|
||||||
let tray_icon = TrayIconBuilder::new()
|
let tray_icon = TrayIconBuilder::new()
|
||||||
.with_menu(Box::new(tray_menu))
|
.with_menu(Box::new(tray_menu))
|
||||||
.with_tooltip("OmniOil Edge Agent")
|
.with_tooltip("OmniOil Edge Agent v2")
|
||||||
.with_icon(icon)
|
.with_icon(icon)
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Canales para comunicación con el hilo de IPC
|
// Canales para comunicación con el hilo de IPC
|
||||||
let (status_tx, status_rx) = tokio::sync::mpsc::channel::<IpcMessage>(10);
|
let (status_tx, status_rx) = tokio::sync::mpsc::channel::<String>(10);
|
||||||
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<AgentCommand>(10);
|
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<AgentCommand>(10);
|
||||||
|
|
||||||
// Hilo de IPC - Usando Tokio Named Pipe Client directamente
|
// Hilo de IPC - Usando Tokio Named Pipe Client directamente
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
use tokio::net::windows::named_pipe::ClientOptions;
|
use tokio::net::windows::named_pipe::ClientOptions;
|
||||||
match ClientOptions::new().open(PIPE_NAME) {
|
match ClientOptions::new().open(PIPE_NAME) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
@@ -207,13 +115,7 @@ mod app {
|
|||||||
let mut reader = BufReader::new(read_half);
|
let mut reader = BufReader::new(read_half);
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
|
|
||||||
let _ = status_tx
|
let _ = status_tx.send("Estado: Agente Conectado".to_string()).await;
|
||||||
.send(IpcMessage::StatusUpdate {
|
|
||||||
status: "Agente Conectado".to_string(),
|
|
||||||
connected_mqtt: false,
|
|
||||||
current_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -222,30 +124,34 @@ mod app {
|
|||||||
Ok(0) | Err(_) => break, // Cierre de conexión
|
Ok(0) | Err(_) => break, // Cierre de conexión
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if let Ok(msg) = serde_json::from_str::<IpcMessage>(&line) {
|
if let Ok(msg) = serde_json::from_str::<IpcMessage>(&line) {
|
||||||
let _ = status_tx.send(msg).await;
|
match msg {
|
||||||
|
IpcMessage::StatusUpdate { status, .. } => {
|
||||||
|
let _ = status_tx.send(format!("Estado: {}", status)).await;
|
||||||
|
}
|
||||||
|
IpcMessage::Notification { title, message } => {
|
||||||
|
println!("[NOTIFY] {}: {}", title, message);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
line.clear();
|
line.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(cmd) = command_rx.recv() => {
|
Some(cmd) = command_rx.recv() => {
|
||||||
if let Ok(payload) = serde_json::to_string(&IpcMessage::Command(cmd)) {
|
let json = serde_json::to_string(&IpcMessage::Command(cmd)).unwrap() + "\n";
|
||||||
let _ = write_half.write_all(format!("{}\n", payload).as_bytes()).await;
|
let _ = write_half.write_all(json.as_bytes()).await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = status_tx
|
let _ = status_tx
|
||||||
.send(IpcMessage::StatusUpdate {
|
.send("Estado: Agente no detectado".to_string())
|
||||||
status: "Agente no detectado".to_string(),
|
|
||||||
connected_mqtt: false,
|
|
||||||
current_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
})
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -257,110 +163,19 @@ mod app {
|
|||||||
status_label,
|
status_label,
|
||||||
command_tx,
|
command_tx,
|
||||||
status_rx,
|
status_rx,
|
||||||
is_updating: false,
|
|
||||||
animation_tick: 0,
|
|
||||||
last_tick_time: std::time::Instant::now(),
|
|
||||||
connected_mqtt: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
event_loop.run_app(&mut app).unwrap();
|
event_loop.run_app(&mut app).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_icon_or_default(path: &std::path::Path, connected: bool) -> tray_icon::Icon {
|
fn load_icon(path: &std::path::Path) -> tray_icon::Icon {
|
||||||
if path.exists() {
|
let (icon_rgba, icon_width, icon_height) = {
|
||||||
if let Ok(image) = image::open(path) {
|
let image = image::open(path)
|
||||||
let image = image.into_rgba8();
|
.expect("Failed to open icon path")
|
||||||
|
.into_rgba8();
|
||||||
let (width, height) = image.dimensions();
|
let (width, height) = image.dimensions();
|
||||||
let rgba = image.into_raw();
|
let rgba = image.into_raw();
|
||||||
if let Ok(icon) = tray_icon::Icon::from_rgba(rgba, width, height) {
|
(rgba, width, height)
|
||||||
return icon;
|
};
|
||||||
}
|
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generar icono de estado elegante de 16x16 en memoria
|
|
||||||
let width = 16;
|
|
||||||
let height = 16;
|
|
||||||
let mut rgba = vec![0u8; (width * height * 4) as usize];
|
|
||||||
for y in 0..height {
|
|
||||||
for x in 0..width {
|
|
||||||
let idx = ((y * width + x) * 4) as usize;
|
|
||||||
|
|
||||||
// Punto de estado en la parte inferior derecha: x: 11..14, y: 11..14
|
|
||||||
let is_dot = x >= 11 && x <= 14 && y >= 11 && y <= 14;
|
|
||||||
if is_dot {
|
|
||||||
if connected {
|
|
||||||
// Punto Verde (Conectado)
|
|
||||||
rgba[idx] = 46;
|
|
||||||
rgba[idx + 1] = 204;
|
|
||||||
rgba[idx + 2] = 113;
|
|
||||||
rgba[idx + 3] = 255;
|
|
||||||
} else {
|
|
||||||
// Punto Rojo (Desconectado)
|
|
||||||
rgba[idx] = 231;
|
|
||||||
rgba[idx + 1] = 76;
|
|
||||||
rgba[idx + 2] = 60;
|
|
||||||
rgba[idx + 3] = 255;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fondo gris slate de tema oscuro moderno
|
|
||||||
rgba[idx] = 44;
|
|
||||||
rgba[idx + 1] = 62;
|
|
||||||
rgba[idx + 2] = 80;
|
|
||||||
rgba[idx + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tray_icon::Icon::from_rgba(rgba, width, height).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_updating_icon(tick: usize) -> tray_icon::Icon {
|
|
||||||
let width = 16;
|
|
||||||
let height = 16;
|
|
||||||
let mut rgba = vec![0u8; (width * height * 4) as usize];
|
|
||||||
|
|
||||||
let num_dots = 8;
|
|
||||||
let dot_idx = tick % num_dots;
|
|
||||||
|
|
||||||
// Círculo de radio 5.0 centrado en 7.5, 7.5
|
|
||||||
let angle = (dot_idx as f64) * (2.0 * std::f64::consts::PI / (num_dots as f64));
|
|
||||||
let active_x = (7.5 + 5.0 * angle.cos()) as i32;
|
|
||||||
let active_y = (7.5 + 5.0 * angle.sin()) as i32;
|
|
||||||
|
|
||||||
for y in 0..height {
|
|
||||||
for x in 0..width {
|
|
||||||
let idx = ((y * width + x) * 4) as usize;
|
|
||||||
|
|
||||||
let dx = x as i32 - active_x;
|
|
||||||
let dy = y as i32 - active_y;
|
|
||||||
let dist_sq = dx * dx + dy * dy;
|
|
||||||
|
|
||||||
if dist_sq <= 1 {
|
|
||||||
// Punto naranja brillante de animación
|
|
||||||
rgba[idx] = 255;
|
|
||||||
rgba[idx + 1] = 165;
|
|
||||||
rgba[idx + 2] = 0;
|
|
||||||
rgba[idx + 3] = 255;
|
|
||||||
} else {
|
|
||||||
// Fondo azul oscuro moderno
|
|
||||||
rgba[idx] = 0;
|
|
||||||
rgba[idx + 1] = 50;
|
|
||||||
rgba[idx + 2] = 100;
|
|
||||||
rgba[idx + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tray_icon::Icon::from_rgba(rgba, width, height).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
app::run();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn main() {
|
|
||||||
println!("System tray companion is only supported on Windows.");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,16 @@
|
|||||||
//! Los datos se cifran con claves del LSA de Windows — si el disco se copia
|
//! Los datos se cifran con claves del LSA de Windows — si el disco se copia
|
||||||
//! a otro equipo, no se pueden descifrar.
|
//! a otro equipo, no se pueden descifrar.
|
||||||
//!
|
//!
|
||||||
//! - Linux: AES-256-GCM con clave derivada vía PBKDF2-HMAC-SHA256 (600K iteraciones).
|
//! - Linux: archivo plano con permisos chmod 600, directorio chmod 700.
|
||||||
//! Cada archivo usa un salt aleatorio único de 16 bytes, almacenado junto al ciphertext.
|
//! La seguridad viene de los permisos del sistema de archivos. El servicio
|
||||||
//! Formato en disco: [salt 16B][nonce 12B][ciphertext].
|
//! corre como usuario dedicado `omnioil-agent` que es el único con acceso.
|
||||||
//!
|
|
||||||
//! - macOS/otros: archivo plano con permisos chmod 600 (sin AES, misma debilidad que antes).
|
|
||||||
|
|
||||||
use aes_gcm::{
|
|
||||||
Aes256Gcm, Nonce,
|
|
||||||
aead::{Aead, KeyInit},
|
|
||||||
};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pbkdf2::pbkdf2_hmac;
|
|
||||||
use rand::RngCore;
|
|
||||||
use sha2::Sha256;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Guarda datos secretos en disco con protección según la plataforma.
|
/// Guarda datos secretos en disco con protección según la plataforma.
|
||||||
/// Windows: cifra con DPAPI (machine-scope) antes de escribir.
|
/// Windows: cifra con DPAPI (machine-scope) antes de escribir.
|
||||||
/// Linux: cifra con AES-256-GCM + PBKDF2 (600K iteraciones, salt aleatorio).
|
/// Linux: escribe en texto plano con chmod 600.
|
||||||
/// Otros: texto plano con chmod 600.
|
|
||||||
pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
|
pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -31,38 +21,7 @@ pub fn save_secret(path: &Path, data: &[u8]) -> Result<()> {
|
|||||||
std::fs::write(path, &encrypted)
|
std::fs::write(path, &encrypted)
|
||||||
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(not(windows))]
|
||||||
{
|
|
||||||
let master = std::env::var("OMNIOIL_MASTER_SECRET")
|
|
||||||
.map_err(|_| anyhow::anyhow!("OMNIOIL_MASTER_SECRET not set"))?;
|
|
||||||
|
|
||||||
// Salt aleatorio de 16 bytes por archivo
|
|
||||||
let salt: [u8; 16] = rand::random();
|
|
||||||
// Nonce de 12 bytes
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
|
|
||||||
// Derivar clave con 600,000 iteraciones
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
pbkdf2_hmac::<Sha256>(master.as_bytes(), &salt, 600_000, &mut key);
|
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
|
||||||
.map_err(|e| anyhow::anyhow!("AES cipher init failed: {}", e))?;
|
|
||||||
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(&Nonce::from_slice(&nonce_bytes), data)
|
|
||||||
.map_err(|e| anyhow::anyhow!("AES encryption failed: {}", e))?;
|
|
||||||
|
|
||||||
// Formato: [salt 16B][nonce 12B][ciphertext]
|
|
||||||
let mut output = Vec::with_capacity(16 + 12 + ciphertext.len());
|
|
||||||
output.extend_from_slice(&salt);
|
|
||||||
output.extend_from_slice(&nonce_bytes);
|
|
||||||
output.extend_from_slice(&ciphertext);
|
|
||||||
|
|
||||||
std::fs::write(path, &output)
|
|
||||||
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
|
||||||
set_secret_permissions(path)?;
|
|
||||||
}
|
|
||||||
#[cfg(not(any(windows, target_os = "linux")))]
|
|
||||||
{
|
{
|
||||||
std::fs::write(path, data)
|
std::fs::write(path, data)
|
||||||
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
.with_context(|| format!("Error guardando secreto en {:?}", path))?;
|
||||||
@@ -79,33 +38,7 @@ pub fn load_secret(path: &Path) -> Result<Vec<u8>> {
|
|||||||
{
|
{
|
||||||
dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI")
|
dpapi::unprotect(&raw).context("Error descifrando secreto con DPAPI")
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(not(windows))]
|
||||||
{
|
|
||||||
if raw.len() < 28 {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Formato de archivo cifrado inválido: esperado >=28 bytes, obtenido {}",
|
|
||||||
raw.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let salt = &raw[..16];
|
|
||||||
let nonce = &raw[16..28];
|
|
||||||
let ciphertext = &raw[28..];
|
|
||||||
|
|
||||||
let master = std::env::var("OMNIOIL_MASTER_SECRET")
|
|
||||||
.map_err(|_| anyhow::anyhow!("OMNIOIL_MASTER_SECRET not set"))?;
|
|
||||||
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
pbkdf2_hmac::<Sha256>(master.as_bytes(), salt, 600_000, &mut key);
|
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
|
||||||
.map_err(|e| anyhow::anyhow!("AES cipher init failed: {}", e))?;
|
|
||||||
|
|
||||||
let plaintext = cipher
|
|
||||||
.decrypt(&Nonce::from_slice(nonce), ciphertext)
|
|
||||||
.map_err(|e| anyhow::anyhow!("AES decryption failed: {}", e))?;
|
|
||||||
|
|
||||||
Ok(plaintext)
|
|
||||||
}
|
|
||||||
#[cfg(not(any(windows, target_os = "linux")))]
|
|
||||||
{
|
{
|
||||||
Ok(raw)
|
Ok(raw)
|
||||||
}
|
}
|
||||||
@@ -146,7 +79,8 @@ fn set_secret_permissions(path: &Path) -> Result<()> {
|
|||||||
mod dpapi {
|
mod dpapi {
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use windows_sys::Win32::Security::Cryptography::{
|
use windows_sys::Win32::Security::Cryptography::{
|
||||||
CRYPT_INTEGER_BLOB, CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
|
CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
|
||||||
|
CRYPT_INTEGER_BLOB,
|
||||||
};
|
};
|
||||||
|
|
||||||
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
|
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
|
||||||
|
|||||||
@@ -13,24 +13,11 @@ use tokio::sync::Mutex;
|
|||||||
use tokio::time::{Duration, interval};
|
use tokio::time::{Duration, interval};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Información necesaria para re-lanzar un task si muere.
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct DeviceTaskSpec {
|
|
||||||
device: shared_lib::edge_models::devices::EdgeDevice,
|
|
||||||
variables: HashMap<String, String>,
|
|
||||||
mqtt: AsyncClient,
|
|
||||||
topic: String,
|
|
||||||
storage: Arc<StoreAndForward>,
|
|
||||||
project_id: Uuid,
|
|
||||||
deadband_pct: f64,
|
|
||||||
reporter: Arc<crate::log_reporter::LogReporter>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DataCollectorManager {
|
pub struct DataCollectorManager {
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
project_id: Uuid,
|
project_id: Uuid,
|
||||||
storage: Arc<StoreAndForward>,
|
storage: Arc<StoreAndForward>,
|
||||||
handles: Arc<Mutex<Vec<(tokio::task::JoinHandle<()>, DeviceTaskSpec)>>>,
|
handles: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
|
||||||
deadband_threshold_pct: f64,
|
deadband_threshold_pct: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,47 +37,6 @@ impl DataCollectorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supervisa periódicamente los tasks de recolección activos.
|
|
||||||
/// Si alguno terminó (panic o retorno inesperado), lo relanza automáticamente
|
|
||||||
/// para garantizar que la recolección de datos **nunca se detenga**.
|
|
||||||
pub async fn start_supervisor(self: &Arc<Self>) {
|
|
||||||
let handles = self.handles.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut timer = interval(Duration::from_secs(30));
|
|
||||||
loop {
|
|
||||||
timer.tick().await;
|
|
||||||
|
|
||||||
let mut guard = handles.lock().await;
|
|
||||||
let mut to_relaunch: Vec<DeviceTaskSpec> = Vec::new();
|
|
||||||
|
|
||||||
// Identificar tasks que terminaron
|
|
||||||
guard.retain(|(handle, spec)| {
|
|
||||||
if handle.is_finished() {
|
|
||||||
tracing::error!(
|
|
||||||
"🔴 SUPERVISOR: Task para dispositivo '{}' terminó inesperadamente. Re-lanzando...",
|
|
||||||
spec.device.name
|
|
||||||
);
|
|
||||||
to_relaunch.push(spec.clone());
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-lanzar tasks muertos
|
|
||||||
for spec in to_relaunch {
|
|
||||||
let device_name = spec.device.name.clone();
|
|
||||||
let handle = tokio::spawn(run_device_collector(spec.clone()));
|
|
||||||
guard.push((handle, spec));
|
|
||||||
tracing::info!(
|
|
||||||
"🔄 SUPERVISOR: Task para dispositivo '{}' re-lanzado exitosamente",
|
|
||||||
device_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_config(
|
pub async fn update_config(
|
||||||
&self,
|
&self,
|
||||||
assets: Vec<AssetConfig>,
|
assets: Vec<AssetConfig>,
|
||||||
@@ -104,7 +50,7 @@ impl DataCollectorManager {
|
|||||||
|
|
||||||
// Stop old tasks
|
// Stop old tasks
|
||||||
let old_count = handles.len();
|
let old_count = handles.len();
|
||||||
for (handle, _) in handles.drain(..) {
|
for handle in handles.drain(..) {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
if old_count > 0 {
|
if old_count > 0 {
|
||||||
@@ -147,6 +93,11 @@ impl DataCollectorManager {
|
|||||||
_ => {} // Protocolo soportado, continuar
|
_ => {} // Protocolo soportado, continuar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mqtt = self.mqtt_client.clone();
|
||||||
|
let topic = telemetry_topic.clone();
|
||||||
|
let storage_clone = self.storage.clone();
|
||||||
|
let project_id = self.project_id;
|
||||||
|
|
||||||
// Map variables
|
// Map variables
|
||||||
let mut variables = HashMap::new();
|
let mut variables = HashMap::new();
|
||||||
for var in device_cfg.variables {
|
for var in device_cfg.variables {
|
||||||
@@ -154,55 +105,14 @@ impl DataCollectorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let var_count = variables.len();
|
let var_count = variables.len();
|
||||||
|
let deadband_pct = self.deadband_threshold_pct;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📡 Starting collector for device '{}' ({} variables)",
|
"📡 Starting collector for device '{}' ({} variables)",
|
||||||
device.name,
|
device.name,
|
||||||
var_count
|
var_count
|
||||||
);
|
);
|
||||||
|
|
||||||
let spec = DeviceTaskSpec {
|
let handle = tokio::spawn(async move {
|
||||||
device: device.clone(),
|
|
||||||
variables,
|
|
||||||
mqtt: self.mqtt_client.clone(),
|
|
||||||
topic: telemetry_topic.clone(),
|
|
||||||
storage: self.storage.clone(),
|
|
||||||
project_id: self.project_id,
|
|
||||||
deadband_pct: self.deadband_threshold_pct,
|
|
||||||
reporter: reporter.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let handle = tokio::spawn(run_device_collector(spec.clone()));
|
|
||||||
handles.push((handle, spec));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = format!(
|
|
||||||
"Data collector operational with {} active drivers",
|
|
||||||
handles.len()
|
|
||||||
);
|
|
||||||
tracing::info!("✅ {}", msg);
|
|
||||||
let _ = log_reporter.info("DEPLOY", &msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loop principal de recolección para un dispositivo individual.
|
|
||||||
/// Extraído como función separada para permitir re-lanzamiento por el supervisor.
|
|
||||||
///
|
|
||||||
/// Incluye **backoff exponencial** en reconexiones:
|
|
||||||
/// 5s → 10s → 20s → 40s → 60s → 5min (máx).
|
|
||||||
/// El backoff se resetea al reconectar exitosamente.
|
|
||||||
async fn run_device_collector(spec: DeviceTaskSpec) {
|
|
||||||
let DeviceTaskSpec {
|
|
||||||
device,
|
|
||||||
variables,
|
|
||||||
mqtt,
|
|
||||||
topic,
|
|
||||||
storage,
|
|
||||||
project_id,
|
|
||||||
deadband_pct,
|
|
||||||
reporter,
|
|
||||||
} = spec;
|
|
||||||
|
|
||||||
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
||||||
let unit_id = device
|
let unit_id = device
|
||||||
@@ -310,58 +220,24 @@ async fn run_device_collector(spec: DeviceTaskSpec) {
|
|||||||
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
||||||
let mut connected_notified = false;
|
let mut connected_notified = false;
|
||||||
|
|
||||||
// ─── Backoff exponencial para reconexiones ──────────────────────────
|
|
||||||
// Evita log spam y tráfico MQTT innecesario cuando un dispositivo
|
|
||||||
// está apagado permanentemente.
|
|
||||||
let mut reconnect_delay = Duration::from_secs(5);
|
|
||||||
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); // 5 min max
|
|
||||||
let mut consecutive_connect_failures: u32 = 0;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
timer.tick().await;
|
timer.tick().await;
|
||||||
|
|
||||||
if !driver.is_alive() {
|
if !driver.is_alive() {
|
||||||
connected_notified = false;
|
connected_notified = false;
|
||||||
|
|
||||||
// Aplicar backoff: esperar antes de reintentar si ya fallamos antes
|
|
||||||
if consecutive_connect_failures > 0 {
|
|
||||||
tracing::debug!(
|
|
||||||
"⏳ Backoff: waiting {:?} before reconnecting to {}",
|
|
||||||
reconnect_delay,
|
|
||||||
device.name
|
|
||||||
);
|
|
||||||
tokio::time::sleep(reconnect_delay).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"🔌 Attempting to connect to {} at {}:{}...",
|
"🔌 Attempting to connect to {} at {}:{}...",
|
||||||
device.name, device.host, device.port
|
device.name, device.host, device.port
|
||||||
);
|
);
|
||||||
// Solo reportar al servidor cada 5 intentos para no saturar logs
|
|
||||||
if consecutive_connect_failures % 5 == 0 {
|
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = driver.connect().await {
|
if let Err(e) = driver.connect().await {
|
||||||
consecutive_connect_failures += 1;
|
let msg =
|
||||||
let msg = format!(
|
format!("❌ Connection failed for {}: {}", device.name, e);
|
||||||
"❌ Connection failed for {} (attempt #{}): {}",
|
|
||||||
device.name, consecutive_connect_failures, e
|
|
||||||
);
|
|
||||||
tracing::warn!("{}", msg);
|
tracing::warn!("{}", msg);
|
||||||
// Solo reportar al servidor en los primeros 3 intentos y luego cada 10
|
|
||||||
if consecutive_connect_failures <= 3 || consecutive_connect_failures % 10 == 0 {
|
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
}
|
|
||||||
|
|
||||||
// Incrementar backoff: 5s → 10s → 20s → 40s → 60s → 300s
|
|
||||||
reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conexión exitosa: resetear backoff
|
|
||||||
consecutive_connect_failures = 0;
|
|
||||||
reconnect_delay = Duration::from_secs(5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !connected_notified {
|
if !connected_notified {
|
||||||
@@ -430,7 +306,7 @@ async fn run_device_collector(spec: DeviceTaskSpec) {
|
|||||||
device.name, e
|
device.name, e
|
||||||
);
|
);
|
||||||
let _ = reporter.error("MQTT", &msg).await;
|
let _ = reporter.error("MQTT", &msg).await;
|
||||||
let _ = storage.save(&topic, &payload_str).await;
|
let _ = storage_clone.save(&topic, &payload_str).await;
|
||||||
} else {
|
} else {
|
||||||
let _ = reporter
|
let _ = reporter
|
||||||
.info(
|
.info(
|
||||||
@@ -462,4 +338,16 @@ async fn run_device_collector(spec: DeviceTaskSpec) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"Data collector operational with {} active drivers",
|
||||||
|
handles.len()
|
||||||
|
);
|
||||||
|
tracing::info!("✅ {}", msg);
|
||||||
|
let _ = log_reporter.info("DEPLOY", &msg).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ use axum::{
|
|||||||
routing::post,
|
routing::post,
|
||||||
};
|
};
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
use tokio::time::{Duration, interval};
|
use tokio::time::{Duration, interval};
|
||||||
|
|||||||
@@ -3,84 +3,39 @@
|
|||||||
//! Suppresses redundant MQTT publishes when a numeric value changes less than
|
//! Suppresses redundant MQTT publishes when a numeric value changes less than
|
||||||
//! `threshold_pct` percent relative to the last published value, reducing
|
//! `threshold_pct` percent relative to the last published value, reducing
|
||||||
//! bandwidth and storage consumption on stable signals.
|
//! bandwidth and storage consumption on stable signals.
|
||||||
//!
|
|
||||||
//! **Forzado por tiempo**: Si una variable no se publica durante más de
|
|
||||||
//! `max_silence_secs` segundos (default 300 = 5 min), se fuerza la publicación
|
|
||||||
//! para cumplir con la Resolución ANH 0651 Art. 17 (almacenamiento periódico).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
struct DeadbandEntry {
|
|
||||||
last_value: f64,
|
|
||||||
last_published_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DeadbandFilter {
|
pub struct DeadbandFilter {
|
||||||
entries: HashMap<String, DeadbandEntry>,
|
last_values: HashMap<String, f64>,
|
||||||
threshold_pct: f64,
|
threshold_pct: f64,
|
||||||
/// Máximo tiempo en segundos sin publicar antes de forzar publicación.
|
|
||||||
/// Default: 300s (5 minutos) para cumplir ANH 0651.
|
|
||||||
max_silence_secs: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeadbandFilter {
|
impl DeadbandFilter {
|
||||||
pub fn new(threshold_pct: f64) -> Self {
|
pub fn new(threshold_pct: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: HashMap::new(),
|
last_values: HashMap::new(),
|
||||||
threshold_pct,
|
threshold_pct,
|
||||||
max_silence_secs: 300, // 5 minutos — ANH 0651 Art. 17
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the value should be published:
|
/// Returns `true` if the value should be published (first reading or change exceeds threshold).
|
||||||
/// - First reading for this key
|
|
||||||
/// - Change exceeds threshold percentage
|
|
||||||
/// - Time since last publish exceeds `max_silence_secs`
|
|
||||||
///
|
|
||||||
/// Updates the stored value when returning `true`.
|
/// Updates the stored value when returning `true`.
|
||||||
pub fn should_publish(&mut self, key: &str, new_val: f64) -> bool {
|
pub fn should_publish(&mut self, key: &str, new_val: f64) -> bool {
|
||||||
match self.entries.get(key) {
|
match self.last_values.get(key).copied() {
|
||||||
None => {
|
None => {
|
||||||
self.entries.insert(
|
self.last_values.insert(key.to_string(), new_val);
|
||||||
key.to_string(),
|
|
||||||
DeadbandEntry {
|
|
||||||
last_value: new_val,
|
|
||||||
last_published_at: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Some(entry) => {
|
Some(last) => {
|
||||||
let change_pct = if entry.last_value == 0.0 {
|
let change_pct = if last == 0.0 {
|
||||||
if new_val == 0.0 {
|
if new_val == 0.0 { 0.0 } else { 100.0 }
|
||||||
0.0
|
|
||||||
} else {
|
} else {
|
||||||
100.0
|
((new_val - last) / last.abs() * 100.0).abs()
|
||||||
}
|
|
||||||
} else {
|
|
||||||
((new_val - entry.last_value) / entry.last_value.abs() * 100.0).abs()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let time_elapsed = entry.last_published_at.elapsed().as_secs();
|
if change_pct > self.threshold_pct {
|
||||||
let force_by_time = time_elapsed >= self.max_silence_secs;
|
self.last_values.insert(key.to_string(), new_val);
|
||||||
|
|
||||||
if change_pct > self.threshold_pct || force_by_time {
|
|
||||||
if force_by_time && change_pct <= self.threshold_pct {
|
|
||||||
tracing::trace!(
|
|
||||||
"⏰ Deadband forced publish for '{}' after {}s of silence (value unchanged: {})",
|
|
||||||
key,
|
|
||||||
time_elapsed,
|
|
||||||
new_val
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self.entries.insert(
|
|
||||||
key.to_string(),
|
|
||||||
DeadbandEntry {
|
|
||||||
last_value: new_val,
|
|
||||||
last_published_at: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
@@ -122,16 +77,4 @@ mod tests {
|
|||||||
f.should_publish("level", 0.0);
|
f.should_publish("level", 0.0);
|
||||||
assert!(f.should_publish("level", 1.0));
|
assert!(f.should_publish("level", 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn forced_publish_after_silence() {
|
|
||||||
let mut f = DeadbandFilter::new(0.5);
|
|
||||||
// Configurar max_silence a 0 para el test
|
|
||||||
f.max_silence_secs = 0;
|
|
||||||
f.should_publish("pressure", 100.0);
|
|
||||||
// Esperar un instante para que elapsed > 0
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
// 0.01% change — below threshold but should force due to time
|
|
||||||
assert!(f.should_publish("pressure", 100.01));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ pub struct ModbusTcpDriver {
|
|||||||
port: u16,
|
port: u16,
|
||||||
unit_id: u8,
|
unit_id: u8,
|
||||||
ctx: Option<Arc<Mutex<Context>>>,
|
ctx: Option<Arc<Mutex<Context>>>,
|
||||||
/// Contador de errores consecutivos de transporte para detectar conexiones muertas.
|
|
||||||
consecutive_transport_errors: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModbusTcpDriver {
|
impl ModbusTcpDriver {
|
||||||
@@ -24,7 +22,6 @@ impl ModbusTcpDriver {
|
|||||||
port,
|
port,
|
||||||
unit_id,
|
unit_id,
|
||||||
ctx: None,
|
ctx: None,
|
||||||
consecutive_transport_errors: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,23 +30,8 @@ impl ModbusTcpDriver {
|
|||||||
impl ProtocolDriver for ModbusTcpDriver {
|
impl ProtocolDriver for ModbusTcpDriver {
|
||||||
async fn connect(&mut self) -> anyhow::Result<()> {
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
||||||
|
let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?;
|
||||||
// Timeout de 10s para la conexión TCP — evita bloqueo si el PLC está apagado
|
|
||||||
let ctx = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
tcp::connect_slave(socket_addr, Slave(self.unit_id)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Modbus TCP connection timeout (10s) for {}:{}",
|
|
||||||
self.host,
|
|
||||||
self.port
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
|
|
||||||
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
||||||
self.consecutive_transport_errors = 0;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"✅ Connected to Modbus TCP device at {}:{}",
|
"✅ Connected to Modbus TCP device at {}:{}",
|
||||||
self.host,
|
self.host,
|
||||||
@@ -60,13 +42,9 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
|
|
||||||
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
self.ctx = None;
|
self.ctx = None;
|
||||||
self.consecutive_transport_errors = 0;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lee variables Modbus con timeout de 5s por lectura individual.
|
|
||||||
/// Si un PLC acepta conexión TCP pero no responde a queries, la lectura
|
|
||||||
/// se cuelga indefinidamente sin timeout.
|
|
||||||
async fn read_variables(
|
async fn read_variables(
|
||||||
&mut self,
|
&mut self,
|
||||||
variables: &HashMap<String, String>,
|
variables: &HashMap<String, String>,
|
||||||
@@ -77,7 +55,6 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
let mut ctx = ctx_mutex.lock().await;
|
let mut ctx = ctx_mutex.lock().await;
|
||||||
let mut results = HashMap::new();
|
let mut results = HashMap::new();
|
||||||
let mut had_transport_error = false;
|
|
||||||
|
|
||||||
for (alias, address) in variables {
|
for (alias, address) in variables {
|
||||||
let (area, addr_str) = if address.contains(':') {
|
let (area, addr_str) = if address.contains(':') {
|
||||||
@@ -88,66 +65,33 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let addr: u16 = addr_str.parse().unwrap_or(0);
|
let addr: u16 = addr_str.parse().unwrap_or(0);
|
||||||
|
match area {
|
||||||
// Timeout de 5s por lectura individual — evita que un PLC inactivo congele la tarea
|
"HR" => match ctx.read_holding_registers(addr, 1).await {
|
||||||
let read_result = match area {
|
Ok(Ok(registers)) => {
|
||||||
"HR" => {
|
|
||||||
tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
ctx.read_holding_registers(addr, 1),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
"IR" => {
|
|
||||||
tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
ctx.read_input_registers(addr, 1),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
tracing::warn!("Unsupported Modbus area: {}", area);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match read_result {
|
|
||||||
Ok(Ok(Ok(registers))) => {
|
|
||||||
if let Some(v) = registers.first() {
|
if let Some(v) = registers.first() {
|
||||||
results.insert(alias.clone(), json!(v));
|
results.insert(alias.clone(), json!(v));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Ok(Err(e))) => {
|
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
|
||||||
tracing::warn!("Modbus {} Exception: {} for addr {}", area, e, addr);
|
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
|
||||||
}
|
},
|
||||||
Ok(Err(e)) => {
|
"IR" => match ctx.read_input_registers(addr, 1).await {
|
||||||
tracing::warn!("Modbus {} Transport error: {} for addr {}", area, e, addr);
|
Ok(Ok(registers)) => {
|
||||||
had_transport_error = true;
|
if let Some(v) = registers.first() {
|
||||||
}
|
results.insert(alias.clone(), json!(v));
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"Modbus {} read timeout (5s) for addr {} — PLC may be unresponsive",
|
|
||||||
area,
|
|
||||||
addr
|
|
||||||
);
|
|
||||||
had_transport_error = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
|
||||||
|
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
|
||||||
|
},
|
||||||
|
_ => tracing::warn!("Unsupported Modbus area: {}", area),
|
||||||
}
|
}
|
||||||
|
|
||||||
if had_transport_error {
|
|
||||||
self.consecutive_transport_errors += 1;
|
|
||||||
} else {
|
|
||||||
self.consecutive_transport_errors = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica si la conexión está activa Y funcional.
|
|
||||||
/// Si ha habido 3+ errores de transporte consecutivos, la conexión se
|
|
||||||
/// considera muerta y se fuerza reconexión.
|
|
||||||
fn is_alive(&self) -> bool {
|
fn is_alive(&self) -> bool {
|
||||||
self.ctx.is_some() && self.consecutive_transport_errors < 3
|
self.ctx.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ pub struct S7CommDriver {
|
|||||||
rack: u16,
|
rack: u16,
|
||||||
slot: u16,
|
slot: u16,
|
||||||
client: Option<Client<tcp::Transport>>,
|
client: Option<Client<tcp::Transport>>,
|
||||||
/// Contador de errores consecutivos para detectar conexiones muertas.
|
|
||||||
consecutive_errors: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl S7CommDriver {
|
impl S7CommDriver {
|
||||||
@@ -24,80 +22,38 @@ impl S7CommDriver {
|
|||||||
rack,
|
rack,
|
||||||
slot,
|
slot,
|
||||||
client: None,
|
client: None,
|
||||||
consecutive_errors: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProtocolDriver for S7CommDriver {
|
impl ProtocolDriver for S7CommDriver {
|
||||||
/// Conecta al PLC S7 usando `spawn_blocking` + timeout para evitar
|
|
||||||
/// bloquear los hilos del runtime de Tokio con la librería síncrona `s7`.
|
|
||||||
async fn connect(&mut self) -> anyhow::Result<()> {
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
let host = self.host.clone();
|
let addr: IpAddr = self.host.parse()?;
|
||||||
let rack = self.rack;
|
let options = tcp::Options::new(addr, self.rack, self.slot, Connection::PG);
|
||||||
let slot = self.slot;
|
let conn = tcp::Transport::connect(options)?;
|
||||||
|
let client = Client::new(conn)?;
|
||||||
// spawn_blocking mueve la operación TCP síncrona a un hilo dedicado.
|
|
||||||
// El timeout evita que un PLC inalcanzable congele el hilo indefinidamente.
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let addr: IpAddr = host.parse()?;
|
|
||||||
let options = tcp::Options::new(addr, rack, slot, Connection::PG);
|
|
||||||
let conn = tcp::Transport::connect(options)
|
|
||||||
.map_err(|e| anyhow::anyhow!("S7 TCP connect: {}", e))?;
|
|
||||||
Client::new(conn).map_err(|e| anyhow::anyhow!("S7 client init: {}", e))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(Ok(client))) => {
|
|
||||||
self.client = Some(client);
|
self.client = Some(client);
|
||||||
self.consecutive_errors = 0;
|
|
||||||
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Ok(Ok(Err(e))) => Err(e),
|
|
||||||
Ok(Err(e)) => Err(anyhow::anyhow!("S7 spawn_blocking join error: {}", e)),
|
|
||||||
Err(_) => Err(anyhow::anyhow!(
|
|
||||||
"S7 connection timeout (10s) for {}",
|
|
||||||
self.host
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
self.client = None;
|
self.client = None;
|
||||||
self.consecutive_errors = 0;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lee variables del PLC usando `spawn_blocking` + timeout.
|
|
||||||
/// `ag_read` es síncrono y puede bloquearse indefinidamente si el PLC
|
|
||||||
/// acepta la conexión TCP pero no responde a las queries.
|
|
||||||
async fn read_variables(
|
async fn read_variables(
|
||||||
&mut self,
|
&mut self,
|
||||||
variables: &HashMap<String, String>,
|
variables: &HashMap<String, String>,
|
||||||
) -> anyhow::Result<HashMap<String, Value>> {
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
let client = self
|
let client = self
|
||||||
.client
|
.client
|
||||||
.take()
|
.as_mut()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
|
|
||||||
let vars_clone: Vec<(String, String)> = variables
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let read_result = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let mut results = HashMap::new();
|
let mut results = HashMap::new();
|
||||||
let mut client = client;
|
|
||||||
|
|
||||||
for (alias, address) in &vars_clone {
|
for (alias, address) in variables {
|
||||||
if address.starts_with("DB") {
|
if address.starts_with("DB") {
|
||||||
let parts: Vec<&str> = address[2..].split('.').collect();
|
let parts: Vec<&str> = address[2..].split('.').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
@@ -114,34 +70,10 @@ impl ProtocolDriver for S7CommDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(client, results)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match read_result {
|
|
||||||
Ok(Ok((client, results))) => {
|
|
||||||
self.client = Some(client);
|
|
||||||
self.consecutive_errors = 0;
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
|
||||||
self.consecutive_errors += 1;
|
|
||||||
Err(anyhow::anyhow!("S7 read spawn_blocking error: {}", e))
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
self.consecutive_errors += 1;
|
|
||||||
// No restauramos self.client — la conexión probablemente está muerta
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"S7 read timeout (10s) — PLC may be unresponsive"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifica si el driver tiene una conexión activa Y no ha acumulado errores.
|
|
||||||
/// 3 errores consecutivos = conexión considerada muerta → fuerza reconexión.
|
|
||||||
fn is_alive(&self) -> bool {
|
fn is_alive(&self) -> bool {
|
||||||
self.client.is_some() && self.consecutive_errors < 3
|
self.client.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
||||||
//! Reporta métricas reales de CPU y memoria usando llamadas nativas del OS.
|
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
@@ -30,10 +29,6 @@ pub async fn start_health_checker(
|
|||||||
let mut timer = interval(check_interval);
|
let mut timer = interval(check_interval);
|
||||||
let mut json_buf = Vec::with_capacity(512);
|
let mut json_buf = Vec::with_capacity(512);
|
||||||
|
|
||||||
// Para cálculo de CPU: guardamos el snapshot anterior
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let mut prev_cpu = read_proc_stat();
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🏥 Native Health checker started (interval: {:?})",
|
"🏥 Native Health checker started (interval: {:?})",
|
||||||
check_interval
|
check_interval
|
||||||
@@ -44,41 +39,6 @@ pub async fn start_health_checker(
|
|||||||
|
|
||||||
let uptime = Utc::now().signed_duration_since(start_time).num_seconds() as u64;
|
let uptime = Utc::now().signed_duration_since(start_time).num_seconds() as u64;
|
||||||
|
|
||||||
// ─── Métricas de CPU ─────────────────────────────────────────────
|
|
||||||
let cpu_usage = {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let current = read_proc_stat();
|
|
||||||
let usage = calculate_cpu_percent(&prev_cpu, ¤t);
|
|
||||||
prev_cpu = current;
|
|
||||||
Some(usage)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
get_cpu_usage_windows()
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
||||||
{
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Métricas de Memoria ─────────────────────────────────────────
|
|
||||||
let memory_mb = {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
get_memory_usage_linux()
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
get_memory_usage_windows()
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
||||||
{
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Construir reporte nativo
|
// Construir reporte nativo
|
||||||
let report = HealthReport {
|
let report = HealthReport {
|
||||||
project_id: project_uuid,
|
project_id: project_uuid,
|
||||||
@@ -89,8 +49,8 @@ pub async fn start_health_checker(
|
|||||||
container_id: None, // Deprecated
|
container_id: None, // Deprecated
|
||||||
image_tag: None, // Deprecated
|
image_tag: None, // Deprecated
|
||||||
uptime_seconds: Some(uptime),
|
uptime_seconds: Some(uptime),
|
||||||
cpu_usage_percent: cpu_usage,
|
cpu_usage_percent: None,
|
||||||
memory_usage_mb: memory_mb.map(|m| m as f64),
|
memory_usage_mb: None,
|
||||||
restart_count: 0,
|
restart_count: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
@@ -106,130 +66,9 @@ pub async fn start_health_checker(
|
|||||||
let _ = mqtt.publish(&topic, QoS::AtMostOnce, false, payload).await;
|
let _ = mqtt.publish(&topic, QoS::AtMostOnce, false, payload).await;
|
||||||
});
|
});
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"📡 Native health report sent (CPU: {:?}%, RAM: {:?}MB, uptime: {}s)",
|
"📡 Native health report sent via MQTT for project: {}",
|
||||||
cpu_usage,
|
project_id
|
||||||
memory_mb,
|
|
||||||
uptime
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Linux: /proc/stat para CPU ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct CpuSnapshot {
|
|
||||||
total: u64,
|
|
||||||
idle: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn read_proc_stat() -> CpuSnapshot {
|
|
||||||
let content = std::fs::read_to_string("/proc/stat").unwrap_or_default();
|
|
||||||
let line = content.lines().next().unwrap_or("");
|
|
||||||
// cpu user nice system idle iowait irq softirq steal guest guest_nice
|
|
||||||
let values: Vec<u64> = line
|
|
||||||
.split_whitespace()
|
|
||||||
.skip(1) // skip "cpu"
|
|
||||||
.filter_map(|v| v.parse().ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let total: u64 = values.iter().sum();
|
|
||||||
let idle = values.get(3).copied().unwrap_or(0) + values.get(4).copied().unwrap_or(0);
|
|
||||||
|
|
||||||
CpuSnapshot { total, idle }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn calculate_cpu_percent(prev: &CpuSnapshot, current: &CpuSnapshot) -> f64 {
|
|
||||||
let total_diff = current.total.saturating_sub(prev.total);
|
|
||||||
let idle_diff = current.idle.saturating_sub(prev.idle);
|
|
||||||
|
|
||||||
if total_diff == 0 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = ((total_diff - idle_diff) as f64 / total_diff as f64) * 100.0;
|
|
||||||
(usage * 10.0).round() / 10.0 // Redondear a 1 decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Linux: /proc/meminfo para RAM ───────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn get_memory_usage_linux() -> Option<u64> {
|
|
||||||
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
|
|
||||||
let mut total_kb: u64 = 0;
|
|
||||||
let mut available_kb: u64 = 0;
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
if let Some(val) = line.strip_prefix("MemTotal:") {
|
|
||||||
total_kb = val.trim().split_whitespace().next()?.parse().ok()?;
|
|
||||||
} else if let Some(val) = line.strip_prefix("MemAvailable:") {
|
|
||||||
available_kb = val.trim().split_whitespace().next()?.parse().ok()?;
|
|
||||||
}
|
|
||||||
if total_kb > 0 && available_kb > 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if total_kb > 0 {
|
|
||||||
Some((total_kb - available_kb) / 1024) // Convertir a MB
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Windows: GlobalMemoryStatusEx para RAM ──────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn get_memory_usage_windows() -> Option<u64> {
|
|
||||||
use std::mem;
|
|
||||||
#[repr(C)]
|
|
||||||
struct MEMORYSTATUSEX {
|
|
||||||
dw_length: u32,
|
|
||||||
dw_memory_load: u32,
|
|
||||||
ull_total_phys: u64,
|
|
||||||
ull_avail_phys: u64,
|
|
||||||
ull_total_page_file: u64,
|
|
||||||
ull_avail_page_file: u64,
|
|
||||||
ull_total_virtual: u64,
|
|
||||||
ull_avail_virtual: u64,
|
|
||||||
ull_avail_extended_virtual: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "system" {
|
|
||||||
fn GlobalMemoryStatusEx(lpBuffer: *mut MEMORYSTATUSEX) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut status: MEMORYSTATUSEX = unsafe { mem::zeroed() };
|
|
||||||
status.dw_length = mem::size_of::<MEMORYSTATUSEX>() as u32;
|
|
||||||
|
|
||||||
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
|
|
||||||
if ok != 0 {
|
|
||||||
let used_bytes = status.ull_total_phys - status.ull_avail_phys;
|
|
||||||
Some(used_bytes / (1024 * 1024)) // Convertir a MB
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn get_cpu_usage_windows() -> Option<f64> {
|
|
||||||
// Usar PowerShell para un snapshot rápido de CPU
|
|
||||||
let output = std::process::Command::new("powershell")
|
|
||||||
.args([
|
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
"(Get-CimInstance Win32_Processor).LoadPercentage",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
let s = String::from_utf8(output.stdout).ok()?;
|
|
||||||
s.trim().parse::<f64>().ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,18 +6,11 @@ pub enum IpcMessage {
|
|||||||
StatusUpdate {
|
StatusUpdate {
|
||||||
status: String,
|
status: String,
|
||||||
connected_mqtt: bool,
|
connected_mqtt: bool,
|
||||||
current_version: String,
|
|
||||||
},
|
},
|
||||||
Notification {
|
Notification {
|
||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
OtaProgress {
|
|
||||||
percentage: u8,
|
|
||||||
stage: String, // "Downloading", "Verifying", "Applying", "Finished", "Error"
|
|
||||||
target_version: String,
|
|
||||||
error_message: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tray -> Agent
|
// Tray -> Agent
|
||||||
Command(AgentCommand),
|
Command(AgentCommand),
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
|
|||||||
loop {
|
loop {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// create_pipe_server es una fn sincrónica: todos los tipos no-Send de
|
// Crear instancia del pipe. Usamos first_pipe_instance(false) para permitir
|
||||||
// Windows (SECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, raw pointers) se
|
// que el SO limpie la instancia anterior si quedó en estado inconsistente
|
||||||
// dropean antes de cualquier .await, satisfaciendo el bound Send de
|
// (ej. el tray icon se desconectó abruptamente). Esto evita que un error
|
||||||
// tokio::spawn.
|
// en la creación del pipe mate silenciosamente toda la tarea IPC.
|
||||||
let server = match create_pipe_server(pipe_name) {
|
let server = match ServerOptions::new()
|
||||||
|
.first_pipe_instance(false)
|
||||||
|
.create(pipe_name)
|
||||||
|
{
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -64,61 +67,6 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crea una nueva instancia del servidor named-pipe con un null DACL (accesible
|
|
||||||
/// por todos los usuarios de la máquina). Es una función sincrónica para que
|
|
||||||
/// los tipos no-Send de Windows nunca escapen al contexto async.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn create_pipe_server(
|
|
||||||
pipe_name: &str,
|
|
||||||
) -> std::io::Result<tokio::net::windows::named_pipe::NamedPipeServer> {
|
|
||||||
use std::ffi::c_void;
|
|
||||||
use std::ptr::addr_of_mut;
|
|
||||||
use windows_sys::Win32::Foundation::{FALSE, TRUE};
|
|
||||||
use windows_sys::Win32::Security::{
|
|
||||||
InitializeSecurityDescriptor, SetSecurityDescriptorDacl, SECURITY_ATTRIBUTES,
|
|
||||||
SECURITY_DESCRIPTOR,
|
|
||||||
};
|
|
||||||
// SECURITY_DESCRIPTOR_REVISION siempre vale 1 según el Windows SDK; la
|
|
||||||
// constante fue eliminada en versiones nuevas de windows-sys.
|
|
||||||
const SECURITY_DESCRIPTOR_REVISION: u32 = 1;
|
|
||||||
|
|
||||||
let mut sec_desc: SECURITY_DESCRIPTOR = unsafe { std::mem::zeroed() };
|
|
||||||
let mut sa: SECURITY_ATTRIBUTES = unsafe { std::mem::zeroed() };
|
|
||||||
|
|
||||||
let use_security = unsafe {
|
|
||||||
InitializeSecurityDescriptor(
|
|
||||||
addr_of_mut!(sec_desc) as *mut c_void,
|
|
||||||
SECURITY_DESCRIPTOR_REVISION,
|
|
||||||
) != 0
|
|
||||||
&& SetSecurityDescriptorDacl(
|
|
||||||
addr_of_mut!(sec_desc) as *mut c_void,
|
|
||||||
TRUE,
|
|
||||||
std::ptr::null_mut(),
|
|
||||||
FALSE,
|
|
||||||
) != 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if use_security {
|
|
||||||
sa.nLength = std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32;
|
|
||||||
sa.lpSecurityDescriptor = addr_of_mut!(sec_desc) as *mut c_void;
|
|
||||||
sa.bInheritHandle = FALSE;
|
|
||||||
let sa_ptr = addr_of_mut!(sa) as *mut c_void;
|
|
||||||
|
|
||||||
// SAFETY: sec_desc y sa son válidos durante esta llamada.
|
|
||||||
// create_with_security_attributes_raw copia lo que necesita antes de
|
|
||||||
// retornar, por lo que los punteros no escapan esta función.
|
|
||||||
unsafe {
|
|
||||||
ServerOptions::new()
|
|
||||||
.first_pipe_instance(false)
|
|
||||||
.create_with_security_attributes_raw(pipe_name, sa_ptr)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ServerOptions::new()
|
|
||||||
.first_pipe_instance(false)
|
|
||||||
.create(pipe_name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
async fn handle_client(
|
async fn handle_client(
|
||||||
server: tokio::net::windows::named_pipe::NamedPipeServer,
|
server: tokio::net::windows::named_pipe::NamedPipeServer,
|
||||||
|
|||||||
@@ -48,12 +48,9 @@ impl LogReporter {
|
|||||||
let mqtt = self.mqtt_client.clone();
|
let mqtt = self.mqtt_client.clone();
|
||||||
let topic = self.log_topic.clone();
|
let topic = self.log_topic.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = mqtt
|
let _ = mqtt
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
|
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
tracing::trace!("Log reporter MQTT publish failed (best-effort): {}", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ use anyhow::{Context, Result};
|
|||||||
use config::AgentConfig;
|
use config::AgentConfig;
|
||||||
use log_reporter::LogReporter;
|
use log_reporter::LogReporter;
|
||||||
use mqtt_listener::AgentCommand;
|
use mqtt_listener::AgentCommand;
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -65,7 +64,6 @@ fn main() -> Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
// Registrar gancho de pánico para capturar cualquier crash en los archivos de log
|
// Registrar gancho de pánico para capturar cualquier crash en los archivos de log
|
||||||
// y hacer un intento best-effort de notificar al servidor vía MQTT.
|
|
||||||
std::panic::set_hook(Box::new(|panic_info| {
|
std::panic::set_hook(Box::new(|panic_info| {
|
||||||
let payload = panic_info.payload();
|
let payload = panic_info.payload();
|
||||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||||
@@ -79,53 +77,7 @@ fn main() -> Result<()> {
|
|||||||
.location()
|
.location()
|
||||||
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||||
.unwrap_or_else(|| "ubicación desconocida".to_string());
|
.unwrap_or_else(|| "ubicación desconocida".to_string());
|
||||||
let crash_msg = format!("🚨 PÁNICO (CRASH) detectado en {}: {}", location, message);
|
tracing::error!("🚨 PÁNICO (CRASH) detectado en {}: {}", location, message);
|
||||||
tracing::error!("{}", crash_msg);
|
|
||||||
|
|
||||||
// Best-effort: intentar notificar el crash al servidor vía MQTT.
|
|
||||||
// Usamos un runtime efímero porque el panic hook es síncrono
|
|
||||||
// y el runtime principal puede estar corrupto.
|
|
||||||
if let Ok(project_id) = std::env::var("OMNIOIL_PROJECT_ID") {
|
|
||||||
let topic = format!("omnioil/logs/{}", project_id);
|
|
||||||
let log_payload = serde_json::json!({
|
|
||||||
"project_id": project_id,
|
|
||||||
"level": "FATAL",
|
|
||||||
"category": "CRASH",
|
|
||||||
"message": crash_msg,
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
|
||||||
});
|
|
||||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
let _ = rt.block_on(async {
|
|
||||||
// Intentar conectar rápido y enviar el crash log
|
|
||||||
if let Ok(mqtt_host) = std::env::var("OMNIOIL_MQTT_HOST") {
|
|
||||||
let port: u16 = std::env::var("OMNIOIL_MQTT_PORT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|p| p.parse().ok())
|
|
||||||
.unwrap_or(1883);
|
|
||||||
let opts = rumqttc::MqttOptions::new("crash-reporter", mqtt_host, port);
|
|
||||||
let (client, mut eventloop) = rumqttc::AsyncClient::new(opts, 4);
|
|
||||||
// Dar 2s para conectar y enviar
|
|
||||||
let _ = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(2),
|
|
||||||
async {
|
|
||||||
let _ = eventloop.poll().await; // ConnAck
|
|
||||||
let _ = client.publish(
|
|
||||||
&topic,
|
|
||||||
rumqttc::QoS::AtMostOnce,
|
|
||||||
false,
|
|
||||||
serde_json::to_vec(&log_payload).unwrap_or_default(),
|
|
||||||
).await;
|
|
||||||
let _ = eventloop.poll().await; // flush
|
|
||||||
}
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Esperar un momento para permitir que el appender no bloqueante escriba el log a disco
|
// Esperar un momento para permitir que el appender no bloqueante escriba el log a disco
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
}));
|
}));
|
||||||
@@ -249,18 +201,8 @@ pub async fn run_agent(
|
|||||||
mqtt_client.clone(),
|
mqtt_client.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Canal para comandos — capacidad 64 para absorber ráfagas de deploy signals
|
// Canal para comandos
|
||||||
// y comandos OTA sin bloquear al mqtt_listener.
|
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16);
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(64);
|
|
||||||
|
|
||||||
// Exportar project_id y MQTT host como env vars para el panic hook
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("OMNIOIL_PROJECT_ID", &config.agent.project_id);
|
|
||||||
std::env::set_var("OMNIOIL_MQTT_HOST", &config.mqtt.host);
|
|
||||||
if let Some(port) = config.mqtt.port {
|
|
||||||
std::env::set_var("OMNIOIL_MQTT_PORT", port.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iniciar el bucle de eventos MQTT
|
// Iniciar el bucle de eventos MQTT
|
||||||
let _mqtt_handle = mqtt_listener::start_mqtt_event_loop(
|
let _mqtt_handle = mqtt_listener::start_mqtt_event_loop(
|
||||||
@@ -398,34 +340,6 @@ pub async fn run_agent(
|
|||||||
data_gateway::start_data_gateway(8081, gateway_mqtt, project_id, gateway_storage).await;
|
data_gateway::start_data_gateway(8081, gateway_mqtt, project_id, gateway_storage).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Drenado independiente de Store & Forward ────────────────────────
|
|
||||||
// Garantiza que la cola local se vacíe incluso cuando solo se usan
|
|
||||||
// dispositivos Modbus/S7 directos (sin Data Gateway HTTP recibiendo datos).
|
|
||||||
// Node-RED fue eliminado — este es el único mecanismo de drenado.
|
|
||||||
let drain_mqtt = mqtt_client.clone();
|
|
||||||
let drain_storage = storage.clone();
|
|
||||||
let drain_reporter = log_reporter.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut timer = tokio::time::interval(std::time::Duration::from_secs(30));
|
|
||||||
loop {
|
|
||||||
timer.tick().await;
|
|
||||||
match drain_storage.process_queue(&drain_mqtt).await {
|
|
||||||
Ok(sent) if sent > 0 => {
|
|
||||||
drain_reporter
|
|
||||||
.info(
|
|
||||||
"STORE_FORWARD",
|
|
||||||
&format!("Drained {} buffered message(s) from local queue", sent),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!("Store & Forward drain cycle error: {}", e);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Iniciar Manager de Colector de Datos (Fase 2)
|
// Iniciar Manager de Colector de Datos (Fase 2)
|
||||||
let deadband_threshold = config.agent.deadband_threshold_pct.unwrap_or(0.5);
|
let deadband_threshold = config.agent.deadband_threshold_pct.unwrap_or(0.5);
|
||||||
let collector_manager = Arc::new(data_collector::DataCollectorManager::new(
|
let collector_manager = Arc::new(data_collector::DataCollectorManager::new(
|
||||||
@@ -435,11 +349,6 @@ pub async fn run_agent(
|
|||||||
deadband_threshold,
|
deadband_threshold,
|
||||||
));
|
));
|
||||||
|
|
||||||
// ─── Supervisor de tasks del Data Collector ──────────────────────────
|
|
||||||
// Monitorea cada 30s que los tasks de recolección sigan vivos.
|
|
||||||
// Si alguno muere (panic, error fatal), lo re-lanza automáticamente.
|
|
||||||
collector_manager.start_supervisor().await;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Actualizar heartbeat del watchdog en cada iteración del loop principal
|
// Actualizar heartbeat del watchdog en cada iteración del loop principal
|
||||||
watchdog_hb_writer.store(
|
watchdog_hb_writer.store(
|
||||||
@@ -469,7 +378,6 @@ pub async fn run_agent(
|
|||||||
let _ = ipc_tx.send(ipc_protocol::IpcMessage::StatusUpdate {
|
let _ = ipc_tx.send(ipc_protocol::IpcMessage::StatusUpdate {
|
||||||
status: "Conectado".to_string(),
|
status: "Conectado".to_string(),
|
||||||
connected_mqtt: true,
|
connected_mqtt: true,
|
||||||
current_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
AgentCommand::Reprovision => {
|
AgentCommand::Reprovision => {
|
||||||
@@ -488,7 +396,6 @@ pub async fn run_agent(
|
|||||||
let prefer_msi = config.updater.as_ref()
|
let prefer_msi = config.updater.as_ref()
|
||||||
.and_then(|u| u.prefer_msi)
|
.and_then(|u| u.prefer_msi)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let ipc_tx_clone = ipc_tx.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
reporter.info("OTA", &format!("Starting OTA update → v{}", target_version)).await;
|
reporter.info("OTA", &format!("Starting OTA update → v{}", target_version)).await;
|
||||||
if download_url.is_empty() {
|
if download_url.is_empty() {
|
||||||
@@ -502,7 +409,6 @@ pub async fn run_agent(
|
|||||||
&asset_type,
|
&asset_type,
|
||||||
verify,
|
verify,
|
||||||
prefer_msi,
|
prefer_msi,
|
||||||
Some(ipc_tx_clone),
|
|
||||||
).await {
|
).await {
|
||||||
reporter.error("OTA", &format!("OTA update failed: {}", e)).await;
|
reporter.error("OTA", &format!("OTA update failed: {}", e)).await;
|
||||||
}
|
}
|
||||||
@@ -557,10 +463,6 @@ pub async fn run_agent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep(std::time::Duration::from_secs(15)) => {
|
|
||||||
// Despierta periódicamente el bucle para actualizar el heartbeat
|
|
||||||
// del watchdog y demostrar que el runtime de Tokio sigue activo.
|
|
||||||
}
|
|
||||||
_ = tokio::signal::ctrl_c() => break,
|
_ = tokio::signal::ctrl_c() => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ use rand::RngCore;
|
|||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::path::Path;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const SALT: &[u8; 15] = b"omnioil_db_salt";
|
||||||
|
|
||||||
pub struct StoreAndForward {
|
pub struct StoreAndForward {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
cipher: Aes256Gcm,
|
cipher: Aes256Gcm,
|
||||||
@@ -29,48 +30,29 @@ pub struct StoreAndForward {
|
|||||||
impl StoreAndForward {
|
impl StoreAndForward {
|
||||||
/// Inicializa la base de datos y prepara el cifrado AES-256.
|
/// Inicializa la base de datos y prepara el cifrado AES-256.
|
||||||
/// Mantiene una conexión persistente con WAL mode para rendimiento óptimo.
|
/// Mantiene una conexión persistente con WAL mode para rendimiento óptimo.
|
||||||
/// Usa salt aleatorio por dispositivo y 600,000 iteraciones PBKDF2 (OWASP 2023).
|
|
||||||
pub fn new(db_path: &str, config_secret: Option<&str>) -> Result<Self> {
|
pub fn new(db_path: &str, config_secret: Option<&str>) -> Result<Self> {
|
||||||
// 1. Asegurar carpeta
|
// 1. Asegurar carpeta
|
||||||
let db_path = Path::new(db_path);
|
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
||||||
if let Some(parent) = db_path.parent() {
|
|
||||||
if !parent.exists() {
|
if !parent.exists() {
|
||||||
std::fs::create_dir_all(parent).context("Failed to create DB directory")?;
|
std::fs::create_dir_all(parent).context("Failed to create DB directory")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Preparar Cifrado con salt aleatorio persistido
|
// 2. Preparar Cifrado
|
||||||
|
// Prioridad: 1. ENV OMNIOIL_MASTER_SECRET, 2. Config master_secret, 3. Fallback (Project ID as salt)
|
||||||
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
||||||
.ok()
|
.ok()
|
||||||
.or_else(|| config_secret.map(|s| s.to_string()))
|
.or_else(|| config_secret.map(|s| s.to_string()))
|
||||||
.ok_or_else(|| {
|
.unwrap_or_else(|| "fallback_unsafe_secret_change_me".to_string());
|
||||||
anyhow::anyhow!(
|
|
||||||
"OMNIOIL_MASTER_SECRET no está configurado. \
|
|
||||||
El edge-agent no puede iniciar sin este secreto maestro."
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Salt aleatorio por dispositivo — persistir junto al DB
|
if master_secret == "fallback_unsafe_secret_change_me" {
|
||||||
let salt_path = db_path.with_extension("salt");
|
tracing::warn!(
|
||||||
let salt: [u8; 16] = if salt_path.exists() {
|
"⚠️ Usando llave de cifrado por defecto. La base de datos local no es segura."
|
||||||
let existing = std::fs::read(&salt_path)
|
);
|
||||||
.context("Failed to read existing salt file")?;
|
}
|
||||||
existing.try_into().unwrap_or_else(|_| {
|
|
||||||
tracing::warn!("Salt file corrupt, generating new salt");
|
|
||||||
let new: [u8; 16] = rand::random();
|
|
||||||
std::fs::write(&salt_path, &new).ok();
|
|
||||||
new
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let new: [u8; 16] = rand::random();
|
|
||||||
std::fs::write(&salt_path, &new)
|
|
||||||
.context("Failed to write salt file")?;
|
|
||||||
new
|
|
||||||
};
|
|
||||||
|
|
||||||
// 600,000 iteraciones PBKDF2-HMAC-SHA256 (OWASP 2023 minimum)
|
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), &salt, 600_000, &mut key);
|
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);
|
||||||
let cipher =
|
let cipher =
|
||||||
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("Cipher Error: {}", e))?;
|
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("Cipher Error: {}", e))?;
|
||||||
|
|
||||||
@@ -82,7 +64,7 @@ impl StoreAndForward {
|
|||||||
"PRAGMA journal_mode = WAL;
|
"PRAGMA journal_mode = WAL;
|
||||||
PRAGMA synchronous = NORMAL;
|
PRAGMA synchronous = NORMAL;
|
||||||
PRAGMA cache_size = -2000;
|
PRAGMA cache_size = -2000;
|
||||||
PRAGMA busy_timeout = 30000;",
|
PRAGMA busy_timeout = 5000;",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Service Manager — Gestiona la instalación y ejecución como servicio de Windows.
|
//! Service Manager — Gestiona la instalación y ejecución como servicio de Windows.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use anyhow::Result;
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
|
|
||||||
@@ -87,11 +87,6 @@ fn service_handler() -> Result<()> {
|
|||||||
tracing::info!("Service stopping via SCM signal");
|
tracing::info!("Service stopping via SCM signal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to load configuration at {:?}: {}", config_path, e);
|
|
||||||
// Esperar un poco antes de salir para que el log se escriba
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to load configuration at {:?}: {}", config_path, e);
|
tracing::error!("Failed to load configuration at {:?}: {}", config_path, e);
|
||||||
@@ -171,9 +166,7 @@ pub fn install_service() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// sc failureflag no existe en versiones antiguas de Windows
|
// sc failureflag no existe en versiones antiguas de Windows
|
||||||
println!(
|
println!("⚠️ sc failureflag not supported (older Windows). Recovery still works for crashes.");
|
||||||
"⚠️ sc failureflag not supported (older Windows). Recovery still works for crashes."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ use anyhow::{Context, Result, bail};
|
|||||||
use semver::Version;
|
use semver::Version;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use crate::ipc_protocol::IpcMessage;
|
|
||||||
|
|
||||||
/// Aplica una actualización OTA a partir de los datos recibidos vía MQTT.
|
/// Aplica una actualización OTA a partir de los datos recibidos vía MQTT.
|
||||||
///
|
///
|
||||||
@@ -39,7 +38,6 @@ use crate::ipc_protocol::IpcMessage;
|
|||||||
/// - `asset_type`: "msi" | "exe".
|
/// - `asset_type`: "msi" | "exe".
|
||||||
/// - `verify_signature`: si es false, omite la verificación del hash (solo para pruebas).
|
/// - `verify_signature`: si es false, omite la verificación del hash (solo para pruebas).
|
||||||
/// - `prefer_msi`: en Windows, instalar vía MSI en lugar de self-replace .exe.
|
/// - `prefer_msi`: en Windows, instalar vía MSI en lugar de self-replace .exe.
|
||||||
/// - `ipc_tx`: transmisor para reportar progreso de la actualización al Tray Icon.
|
|
||||||
pub async fn apply_ota_from_command(
|
pub async fn apply_ota_from_command(
|
||||||
target_version: &str,
|
target_version: &str,
|
||||||
download_url: &str,
|
download_url: &str,
|
||||||
@@ -47,7 +45,6 @@ pub async fn apply_ota_from_command(
|
|||||||
asset_type: &str,
|
asset_type: &str,
|
||||||
verify_signature: bool,
|
verify_signature: bool,
|
||||||
prefer_msi: bool,
|
prefer_msi: bool,
|
||||||
ipc_tx: Option<tokio::sync::broadcast::Sender<IpcMessage>>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let current_raw = env!("CARGO_PKG_VERSION");
|
let current_raw = env!("CARGO_PKG_VERSION");
|
||||||
let current = Version::parse(current_raw)
|
let current = Version::parse(current_raw)
|
||||||
@@ -75,99 +72,20 @@ pub async fn apply_ota_from_command(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enviar señal inicial de progreso
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Downloading".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.user_agent(format!("omnioil-edge-agent/{}", current_raw))
|
.user_agent(format!("omnioil-edge-agent/{}", current_raw))
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
.context("Error al construir cliente HTTP para descarga OTA")?;
|
.context("Error al construir cliente HTTP para descarga OTA")?;
|
||||||
|
|
||||||
let content = match download_asset(&client, download_url, target_version, ipc_tx.as_ref()).await {
|
let content = download_asset(&client, download_url).await?;
|
||||||
Ok(c) => c,
|
verify_sha256(&content, sha256, verify_signature)?;
|
||||||
Err(e) => {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Error".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: Some(format!("Descarga fallida: {}", e)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verificando SHA-256
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 80,
|
|
||||||
stage: "Verifying".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = verify_sha256(&content, sha256, verify_signature) {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Error".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: Some(format!("Fallo de verificación SHA256: {}", e)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aplicando actualización
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 90,
|
|
||||||
stage: "Applying".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let use_msi = cfg!(windows) && prefer_msi && asset_type == "msi";
|
let use_msi = cfg!(windows) && prefer_msi && asset_type == "msi";
|
||||||
let apply_res = if use_msi {
|
if use_msi {
|
||||||
apply_msi_update(content).await
|
apply_msi_update(content).await
|
||||||
} else {
|
} else {
|
||||||
apply_exe_update(content).await
|
apply_exe_update(content).await
|
||||||
};
|
|
||||||
|
|
||||||
match apply_res {
|
|
||||||
Ok(_) => {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 100,
|
|
||||||
stage: "Finished".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Error".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: Some(format!("Fallo al aplicar actualización: {}", e)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +121,8 @@ fn verify_sha256(content: &[u8], expected: Option<&str>, verify: bool) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Descarga asset desde la URL pre-firmada de MinIO en chunks para reportar progreso.
|
/// Descarga asset desde la URL pre-firmada de MinIO.
|
||||||
async fn download_asset(
|
async fn download_asset(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
||||||
client: &reqwest::Client,
|
|
||||||
url: &str,
|
|
||||||
target_version: &str,
|
|
||||||
ipc_tx: Option<&tokio::sync::broadcast::Sender<IpcMessage>>,
|
|
||||||
) -> Result<Vec<u8>> {
|
|
||||||
info!("⬇️ Downloading from: {}", url);
|
info!("⬇️ Downloading from: {}", url);
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -225,39 +138,11 @@ async fn download_asset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_size = response.content_length().unwrap_or(0);
|
Ok(response
|
||||||
let mut downloaded: u64 = 0;
|
.bytes()
|
||||||
let mut content = Vec::with_capacity(total_size as usize);
|
.await
|
||||||
let mut last_reported_percentage = 0;
|
.context("Failed to read asset bytes")?
|
||||||
|
.to_vec())
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
|
|
||||||
while let Some(item) = stream.next().await {
|
|
||||||
let chunk = item.context("Error reading response bytes stream")?;
|
|
||||||
content.extend_from_slice(&chunk);
|
|
||||||
downloaded += chunk.len() as u64;
|
|
||||||
|
|
||||||
if total_size > 0 {
|
|
||||||
let percentage = ((downloaded as f64 / total_size as f64) * 100.0) as u8;
|
|
||||||
// Reportar en pasos de 5% para no saturar el canal de IPC
|
|
||||||
if percentage >= last_reported_percentage + 5 || percentage == 100 {
|
|
||||||
last_reported_percentage = percentage;
|
|
||||||
if let Some(tx) = ipc_tx {
|
|
||||||
// El progreso de descarga representará del 0% al 75% del total
|
|
||||||
let display_pct = (percentage as f64 * 0.75) as u8;
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: display_pct,
|
|
||||||
stage: "Downloading".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instala vía MSI (Windows). Descarga atómica: .tmp → rename → msiexec.
|
/// Instala vía MSI (Windows). Descarga atómica: .tmp → rename → msiexec.
|
||||||
@@ -314,6 +199,6 @@ async fn apply_exe_update(content: Vec<u8>) -> Result<()> {
|
|||||||
"✅ Binary replaced. Saliendo para que el Service Manager reinicie con el nuevo binario."
|
"✅ Binary replaced. Saliendo para que el Service Manager reinicie con el nuevo binario."
|
||||||
);
|
);
|
||||||
|
|
||||||
// exit(1) → Windows SCM (con failureflag=1) / systemd reinicia el servicio con el nuevo .exe.
|
// exit(0) → Windows SCM / systemd reinicia el servicio con el nuevo .exe.
|
||||||
std::process::exit(1);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,11 @@
|
|||||||
Compressed='yes'
|
Compressed='yes'
|
||||||
SummaryCodepage='1252' />
|
SummaryCodepage='1252' />
|
||||||
|
|
||||||
<MajorUpgrade DowngradeErrorMessage="Una versión más reciente de [ProductName] ya está instalada." />
|
<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]" />
|
||||||
|
|
||||||
<!-- Add custom icon for Add/Remove Programs (Control Panel) -->
|
|
||||||
<Icon Id="ProductIcon" SourceFile="icon_app.ico" />
|
|
||||||
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
|
|
||||||
|
|
||||||
<Directory Id='TARGETDIR' Name='SourceDir'>
|
<Directory Id='TARGETDIR' Name='SourceDir'>
|
||||||
<Directory Id='SystemFolder' />
|
|
||||||
<Directory Id='ProgramFiles64Folder' Name='PFiles'>
|
<Directory Id='ProgramFiles64Folder' Name='PFiles'>
|
||||||
<Directory Id='OmniOilFolder' Name='OmniOil'>
|
<Directory Id='OmniOilFolder' Name='OmniOil'>
|
||||||
<Directory Id='INSTALLDIR' Name='EdgeAgent'>
|
<Directory Id='INSTALLDIR' Name='EdgeAgent'>
|
||||||
@@ -40,12 +35,11 @@
|
|||||||
Type="ownProcess"
|
Type="ownProcess"
|
||||||
Name="OmniOilEdgeAgent"
|
Name="OmniOilEdgeAgent"
|
||||||
DisplayName="OmniOil Edge Agent"
|
DisplayName="OmniOil Edge Agent"
|
||||||
Description="Edge agent for OmniOil (v{{VERSION}})."
|
Description="Edge agent for OmniOil container orchestration (v{{VERSION}})."
|
||||||
Start="auto"
|
Start="auto"
|
||||||
Account="LocalSystem"
|
Account="LocalSystem"
|
||||||
ErrorControl="normal" />
|
ErrorControl="normal" />
|
||||||
<ServiceControl Id="StartService" Start="install" Stop="both" Remove="uninstall" Name="OmniOilEdgeAgent" Wait="yes" />
|
<ServiceControl Id="StartService" Start="install" Stop="both" Remove="uninstall" Name="OmniOilEdgeAgent" Wait="yes" />
|
||||||
|
|
||||||
</Component>
|
</Component>
|
||||||
|
|
||||||
<Component Id='TrayExecutable' Guid='*'>
|
<Component Id='TrayExecutable' Guid='*'>
|
||||||
@@ -84,12 +78,5 @@
|
|||||||
de despliegue o administracion remota.
|
de despliegue o administracion remota.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- Custom Action to close the Tray app before validating locks -->
|
|
||||||
<CustomAction Id="KillTray" Directory="SystemFolder" ExeCommand="taskkill /F /IM edge-tray.exe" Execute="immediate" Return="ignore" />
|
|
||||||
|
|
||||||
<InstallExecuteSequence>
|
|
||||||
<Custom Action="KillTray" Before="InstallValidate" />
|
|
||||||
</InstallExecuteSequence>
|
|
||||||
|
|
||||||
</Product>
|
</Product>
|
||||||
</Wix>
|
</Wix>
|
||||||
@@ -16,6 +16,3 @@ tracing = { workspace = true }
|
|||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
axum = { workspace = true }
|
|
||||||
tower = { workspace = true }
|
|
||||||
prometheus = { workspace = true }
|
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
//! de dominio entre microservicios a través de Redis Streams.
|
//! de dominio entre microservicios a través de Redis Streams.
|
||||||
|
|
||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
mod metrics;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -18,45 +16,7 @@ use tracing::{info, warn};
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt::init();
|
||||||
.json()
|
|
||||||
.flatten_event(false)
|
|
||||||
.with_env_filter(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| "info".into()),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// Metrics HTTP server
|
|
||||||
let metrics_app = axum::Router::new().route(
|
|
||||||
"/metrics",
|
|
||||||
axum::routing::get(|| async {
|
|
||||||
use prometheus::TextEncoder;
|
|
||||||
let encoder = TextEncoder::new();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
encoder
|
|
||||||
.encode_utf8(&prometheus::gather(), &mut buffer)
|
|
||||||
.unwrap();
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::OK,
|
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
|
||||||
buffer,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let metrics_addr: SocketAddr = ([0, 0, 0, 0], 9092).into();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match tokio::net::TcpListener::bind(metrics_addr).await {
|
|
||||||
Ok(listener) => {
|
|
||||||
if let Err(e) = axum::serve(listener, metrics_app).await {
|
|
||||||
tracing::error!("Metrics HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to bind metrics endpoint on {}: {}", metrics_addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||||
@@ -65,36 +25,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Conexiones de infraestructura
|
// Conexiones de infraestructura
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(
|
.max_connections(5)
|
||||||
env::var("DB_MAX_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(5),
|
|
||||||
)
|
|
||||||
.min_connections(
|
|
||||||
env::var("DB_MIN_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)
|
|
||||||
.idle_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(600),
|
|
||||||
))
|
|
||||||
.max_lifetime(Duration::from_secs(
|
|
||||||
env::var("DB_MAX_LIFETIME_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1800),
|
|
||||||
))
|
|
||||||
.acquire_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_ACQUIRE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
))
|
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await?;
|
.await?;
|
||||||
info!("✅ Connected to PostgreSQL.");
|
info!("✅ Connected to PostgreSQL.");
|
||||||
@@ -128,14 +59,10 @@ async fn main() -> Result<()> {
|
|||||||
"📬 Procesando {} eventos pendientes...",
|
"📬 Procesando {} eventos pendientes...",
|
||||||
pending_events.len()
|
pending_events.len()
|
||||||
);
|
);
|
||||||
metrics::OUTBOX_BATCH_SIZE.set(pending_events.len() as f64);
|
|
||||||
|
|
||||||
for event in pending_events {
|
for event in pending_events {
|
||||||
if let Err(e) = dispatcher::dispatch_event(&pool, &mut redis_conn, event).await {
|
if let Err(e) = dispatcher::dispatch_event(&pool, &mut redis_conn, event).await {
|
||||||
warn!("⚠️ Despacho parcialmente fallido: {}. Reintentando...", e);
|
warn!("⚠️ Despacho parcialmente fallido: {}. Reintentando...", e);
|
||||||
metrics::OUTBOX_EVENTS_FAILED.inc();
|
|
||||||
} else {
|
|
||||||
metrics::OUTBOX_EVENTS_PROCESSED.inc();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
use prometheus::{register_counter, register_gauge, Counter, Gauge};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
pub static OUTBOX_EVENTS_PROCESSED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_relay_events_processed_total",
|
|
||||||
"Total outbox events successfully relayed"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static OUTBOX_EVENTS_FAILED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_relay_events_failed_total",
|
|
||||||
"Total outbox events that failed to relay"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static OUTBOX_BATCH_SIZE: LazyLock<Gauge> = LazyLock::new(|| {
|
|
||||||
register_gauge!(
|
|
||||||
"omnioil_relay_batch_size",
|
|
||||||
"Current outbox batch size being processed"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { act } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import SessionBootstrap from './SessionBootstrap'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
|
||||||
import { SessionCoordinator } from '@/lib/api/client'
|
|
||||||
|
|
||||||
vi.mock('@/lib/api/client', () => ({
|
|
||||||
SessionCoordinator: {
|
|
||||||
refresh: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const refreshSession = vi.mocked(SessionCoordinator.refresh)
|
|
||||||
|
|
||||||
describe('SessionBootstrap', () => {
|
|
||||||
let container: HTMLDivElement
|
|
||||||
let root: ReturnType<typeof createRoot>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
root = createRoot(container)
|
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
|
||||||
useAuthStore.persist.clearStorage()
|
|
||||||
refreshSession.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function waitForBootstrap() {
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
||||||
if (useAuthStore.getState().hasBootstrapped) return
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => root.unmount())
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refreshes from the HttpOnly cookie when user metadata is persisted but tokens are not in memory', async () => {
|
|
||||||
useAuthStore.setState({
|
|
||||||
accessToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
token: null,
|
|
||||||
user: { email: 'admin@omnioil.app', role: 'admin' },
|
|
||||||
projects: [{ id: 'project-1', name: 'Piloto' }],
|
|
||||||
activeProjectId: 'project-1',
|
|
||||||
hasBootstrapped: false,
|
|
||||||
isBootstrapping: false,
|
|
||||||
})
|
|
||||||
refreshSession.mockImplementation(async () => {
|
|
||||||
useAuthStore.getState().setSession({
|
|
||||||
accessToken: 'fresh-access-token',
|
|
||||||
refreshToken: null,
|
|
||||||
role: 'admin',
|
|
||||||
email: 'admin@omnioil.app',
|
|
||||||
projects: [{ id: 'project-1', name: 'Piloto' }],
|
|
||||||
})
|
|
||||||
return 'fresh-access-token'
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<SessionBootstrap><div>App lista</div></SessionBootstrap>)
|
|
||||||
})
|
|
||||||
await waitForBootstrap()
|
|
||||||
|
|
||||||
expect(refreshSession).toHaveBeenCalledTimes(1)
|
|
||||||
expect(useAuthStore.getState().accessToken).toBe('fresh-access-token')
|
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true)
|
|
||||||
expect(container.textContent).toContain('App lista')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -9,14 +9,16 @@ interface SessionBootstrapProps {
|
|||||||
|
|
||||||
export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
|
const refreshToken = useAuthStore((state) => state.refreshToken)
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
const hasBootstrapped = useAuthStore((state) => state.hasBootstrapped)
|
const hasBootstrapped = useAuthStore((state) => state.hasBootstrapped)
|
||||||
|
const isBootstrapping = useAuthStore((state) => state.isBootstrapping)
|
||||||
const beginBootstrap = useAuthStore((state) => state.beginBootstrap)
|
const beginBootstrap = useAuthStore((state) => state.beginBootstrap)
|
||||||
const completeBootstrap = useAuthStore((state) => state.completeBootstrap)
|
const completeBootstrap = useAuthStore((state) => state.completeBootstrap)
|
||||||
const logout = useAuthStore((state) => state.logout)
|
const logout = useAuthStore((state) => state.logout)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasBootstrapped) {
|
if (hasBootstrapped || isBootstrapping) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +34,9 @@ export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.email) {
|
if (!refreshToken || !user?.email) {
|
||||||
if (user) {
|
if (user) {
|
||||||
logout('bootstrap-missing-user-email')
|
logout('bootstrap-missing-refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -60,8 +62,10 @@ export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
accessToken,
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
user,
|
user,
|
||||||
hasBootstrapped,
|
hasBootstrapped,
|
||||||
|
isBootstrapping,
|
||||||
beginBootstrap,
|
beginBootstrap,
|
||||||
completeBootstrap,
|
completeBootstrap,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
ShieldCheck, Clock, Info, Lock, UserPlus, LogIn,
|
|
||||||
LogOut, AlertTriangle, UserCog, FolderOpen
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils/cn'
|
|
||||||
|
|
||||||
const SAMPLE_AUDIT_LOGS = [
|
|
||||||
{ id: '1', timestamp: '2025-01-15 08:32:11', user: 'admin@omnioil.app', action: 'LOGIN_SUCCESS', resource: 'AUTH', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
{ id: '2', timestamp: '2025-01-15 09:14:05', user: 'admin@omnioil.app', action: 'CREATE_USER', resource: 'USER:ops@petrocol.com', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
{ id: '3', timestamp: '2025-01-15 10:02:44', user: 'admin@omnioil.app', action: 'CREATE_PROJECT', resource: 'PROJECT:Campo Norte Alpha', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
{ id: '4', timestamp: '2025-01-15 11:47:23', user: 'unknown', action: 'LOGIN_FAILURE', resource: 'AUTH', ip: '45.33.99.201', status: 'FAILURE' },
|
|
||||||
{ id: '5', timestamp: '2025-01-15 14:20:01', user: 'admin@omnioil.app', action: 'UPDATE_USER', resource: 'USER:ops@petrocol.com', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type ActionIconKey = 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'CREATE_USER' | 'CREATE_PROJECT' | 'UPDATE_USER'
|
|
||||||
|
|
||||||
const ACTION_ICONS: Record<ActionIconKey, React.ElementType> = {
|
|
||||||
LOGIN_SUCCESS: LogIn,
|
|
||||||
LOGIN_FAILURE: AlertTriangle,
|
|
||||||
CREATE_USER: UserPlus,
|
|
||||||
CREATE_PROJECT: FolderOpen,
|
|
||||||
UPDATE_USER: UserCog,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuditPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-xl font-semibold">Centro de Auditoria</h1>
|
|
||||||
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs">
|
|
||||||
Proximamente
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Trazabilidad ISO 27001 · Registro inmutable de eventos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-blue-500/20 bg-blue-950/10">
|
|
||||||
<CardContent className="p-4 flex items-start gap-3">
|
|
||||||
<div className="p-2 rounded-md bg-blue-500/10 shrink-0">
|
|
||||||
<Info className="h-4 w-4 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Registros de Auditoria — Fase de Desarrollo</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
||||||
Los registros se almacenan en PostgreSQL bajo el estandar ISO 27001.
|
|
||||||
Para consultar los registros completos, utiliza el API Explorer.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1.5 mt-2">
|
|
||||||
<Lock className="h-3 w-3 text-emerald-400" />
|
|
||||||
<span className="text-xs text-emerald-400">Cumplimiento ANH Resolucion 0651 · ISO 27001</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
{ label: 'ISO 27001', color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
|
|
||||||
{ label: 'GDPR Ready', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
|
||||||
{ label: 'ANH Res. 0651', color: 'bg-primary/20 text-primary border-primary/30' },
|
|
||||||
{ label: 'Trazabilidad Completa', color: 'bg-violet-500/20 text-violet-400 border-violet-500/30' },
|
|
||||||
].map(({ label, color }) => (
|
|
||||||
<Badge key={label} className={cn('text-xs border', color)}>
|
|
||||||
{label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Muestra de Registros (Datos de Demostracion)</p>
|
|
||||||
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs">DEMO</Badge>
|
|
||||||
</div>
|
|
||||||
<Card className="border-border overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border bg-secondary/30">
|
|
||||||
{['Timestamp', 'Usuario', 'Accion', 'Recurso', 'IP', 'Estado'].map((col) => (
|
|
||||||
<th key={col} className="text-left px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">
|
|
||||||
{col}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{SAMPLE_AUDIT_LOGS.map((log) => {
|
|
||||||
const ActionIcon = ACTION_ICONS[log.action as ActionIconKey] ?? Clock
|
|
||||||
const isFailure = log.status === 'FAILURE'
|
|
||||||
return (
|
|
||||||
<tr key={log.id} className="hover:bg-secondary/20 transition-colors">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{log.timestamp}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs font-medium">{log.user}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ActionIcon className={cn('h-3.5 w-3.5 shrink-0', isFailure ? 'text-red-400' : 'text-primary')} />
|
|
||||||
<span className={cn('font-mono text-xs', isFailure ? 'text-red-400' : 'text-primary')}>{log.action}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">{log.resource}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{log.ip}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge className={cn(
|
|
||||||
'text-xs border-0',
|
|
||||||
isFailure ? 'bg-red-500/20 text-red-400' : 'bg-emerald-500/20 text-emerald-400'
|
|
||||||
)}>
|
|
||||||
{log.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
|
||||||
Los registros en tiempo real estaran disponibles en la proxima version del API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ export interface BillingUsage {
|
|||||||
project_name: string
|
project_name: string
|
||||||
tier: string
|
tier: string
|
||||||
subscription_status: string
|
subscription_status: string
|
||||||
hf_variables: number
|
|
||||||
lf_variables: number
|
|
||||||
active_tags: number
|
active_tags: number
|
||||||
tag_limit: number
|
tag_limit: number
|
||||||
price_per_tag: number
|
price_per_tag: number
|
||||||
@@ -18,44 +16,6 @@ export interface BillingUsage {
|
|||||||
period_start: string
|
period_start: string
|
||||||
period_end: string
|
period_end: string
|
||||||
last_snapshot: string | null
|
last_snapshot: string | null
|
||||||
commercial_profile: CommercialProfileBillingSummary | null
|
|
||||||
formula: OperativeFormulaSummary | null
|
|
||||||
calculation_lines: BillingCalculationLine[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommercialProfileBillingSummary {
|
|
||||||
id: string
|
|
||||||
status: string
|
|
||||||
plan: 'free' | 'operative' | 'enterprise' | string
|
|
||||||
payment_method: string | null
|
|
||||||
payment_terms_days: number | null
|
|
||||||
credit_limit_cop: number | null
|
|
||||||
tg_limit: number | null
|
|
||||||
effective_from: string
|
|
||||||
profile_complete: boolean
|
|
||||||
missing_fields: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OperativeFormulaSummary {
|
|
||||||
hf_tags: number
|
|
||||||
lf_tags: number
|
|
||||||
equivalent_tg: number
|
|
||||||
pbase_cop: number
|
|
||||||
k: number
|
|
||||||
annual_cop: number
|
|
||||||
monthly_cop: number
|
|
||||||
currency: string
|
|
||||||
rounding_policy: string
|
|
||||||
effective_from: string
|
|
||||||
effective_hf_price_cop?: number
|
|
||||||
effective_lf_price_cop?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BillingCalculationLine {
|
|
||||||
kind: string
|
|
||||||
label: string
|
|
||||||
amount_cop: number
|
|
||||||
source: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { act } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import BillingPage from './BillingPage'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
|
||||||
import { getBillingUsage, getInvoices, type BillingUsage, type Invoice } from '../api/billing-api'
|
|
||||||
|
|
||||||
vi.mock('../api/billing-api', () => ({
|
|
||||||
getBillingUsage: vi.fn(),
|
|
||||||
getInvoices: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const COP = (amount: number) =>
|
|
||||||
new Intl.NumberFormat('es-CO', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'COP',
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount)
|
|
||||||
|
|
||||||
const usage: BillingUsage = {
|
|
||||||
project_id: 'project-1',
|
|
||||||
project_name: 'Campo Norte',
|
|
||||||
tier: 'standard',
|
|
||||||
subscription_status: 'active',
|
|
||||||
hf_variables: 8,
|
|
||||||
lf_variables: 4,
|
|
||||||
active_tags: 12,
|
|
||||||
tag_limit: 100,
|
|
||||||
price_per_tag: 110000,
|
|
||||||
min_annual_fee: 0,
|
|
||||||
subtotal_annual: 1320000,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: 1320000,
|
|
||||||
total_monthly_cop: 110000,
|
|
||||||
period_start: '2026-01-01T00:00:00.000Z',
|
|
||||||
period_end: '2026-12-31T00:00:00.000Z',
|
|
||||||
last_snapshot: null,
|
|
||||||
commercial_profile: {
|
|
||||||
id: 'profile-1',
|
|
||||||
status: 'active',
|
|
||||||
plan: 'operative',
|
|
||||||
payment_method: 'wire_transfer',
|
|
||||||
payment_terms_days: 30,
|
|
||||||
credit_limit_cop: 50000000,
|
|
||||||
tg_limit: 120,
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
profile_complete: true,
|
|
||||||
missing_fields: [],
|
|
||||||
},
|
|
||||||
formula: {
|
|
||||||
hf_tags: 8,
|
|
||||||
lf_tags: 4,
|
|
||||||
equivalent_tg: 9.6,
|
|
||||||
pbase_cop: 110000,
|
|
||||||
k: 0.1,
|
|
||||||
annual_cop: 840946,
|
|
||||||
monthly_cop: 70079,
|
|
||||||
currency: 'COP',
|
|
||||||
rounding_policy: 'nearest peso',
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
effective_hf_price_cop: 87600,
|
|
||||||
effective_lf_price_cop: 35040,
|
|
||||||
},
|
|
||||||
calculation_lines: [
|
|
||||||
{
|
|
||||||
kind: 'operative_formula',
|
|
||||||
label: 'V = HF + (LF * 0.4); annual = V * Pbase * V^-k',
|
|
||||||
amount_cop: 840946,
|
|
||||||
source: 'billing_rules',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoice: Invoice = {
|
|
||||||
id: 'invoice-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
invoice_number: 'FAC-2026-001',
|
|
||||||
period_start: '2026-01-01T00:00:00.000Z',
|
|
||||||
period_end: '2026-01-31T00:00:00.000Z',
|
|
||||||
active_tags: 9,
|
|
||||||
unit_price: 110000,
|
|
||||||
subtotal: 990000,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_cop: 990000,
|
|
||||||
status: 'sent',
|
|
||||||
due_date: null,
|
|
||||||
paid_at: null,
|
|
||||||
notes: null,
|
|
||||||
created_at: '2026-02-01T00:00:00.000Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAuthStore() {
|
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
|
||||||
useAuthStore.persist.clearStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedAdminSession() {
|
|
||||||
useAuthStore.getState().setSession({
|
|
||||||
accessToken: 'access-token',
|
|
||||||
refreshToken: null,
|
|
||||||
role: 'admin',
|
|
||||||
email: 'admin@omnioil.app',
|
|
||||||
projects: [{ id: 'project-1', name: 'Campo Norte' }],
|
|
||||||
})
|
|
||||||
useAuthStore.getState().setActiveProject('project-1')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushEffects() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BillingPage current usage split', () => {
|
|
||||||
let container: HTMLDivElement
|
|
||||||
let root: ReturnType<typeof createRoot>
|
|
||||||
let queryClient: QueryClient
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetAuthStore()
|
|
||||||
seedAdminSession()
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue(usage)
|
|
||||||
vi.mocked(getInvoices).mockResolvedValue([invoice])
|
|
||||||
|
|
||||||
container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
root = createRoot(container)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount()
|
|
||||||
})
|
|
||||||
queryClient.clear()
|
|
||||||
resetAuthStore()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
container.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function renderBillingPage() {
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<BillingPage />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushEffects()
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders Operative formula values returned by the backend', async () => {
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(getBillingUsage).toHaveBeenCalledWith('project-1')
|
|
||||||
expect(container.textContent).toContain('Plan Operative')
|
|
||||||
expect(container.textContent).toContain('HF tags')
|
|
||||||
expect(container.textContent).toContain('Alta frecuencia en fórmula backend8')
|
|
||||||
expect(container.textContent).toContain('LF tags')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia con factor 0.4 TG4')
|
|
||||||
expect(container.textContent).toContain('TG equivalente 9,6')
|
|
||||||
expect(container.textContent).toContain(`Pbase ${COP(110000)}`)
|
|
||||||
expect(container.textContent).toContain('k 0,1')
|
|
||||||
expect(container.textContent).toContain('V^-k')
|
|
||||||
expect(container.textContent).toContain(COP(840946))
|
|
||||||
expect(container.textContent).toContain(COP(70079))
|
|
||||||
expect(container.textContent).toContain(COP(87600))
|
|
||||||
expect(container.textContent).toContain(COP(35040))
|
|
||||||
expect(container.textContent).not.toContain('25 tags')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders Free as non-collecting with limits and no fake annual or monthly charge', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue({
|
|
||||||
...usage,
|
|
||||||
tier: 'free',
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
price_per_tag: 0,
|
|
||||||
commercial_profile: {
|
|
||||||
...usage.commercial_profile!,
|
|
||||||
plan: 'free',
|
|
||||||
payment_method: 'none',
|
|
||||||
payment_terms_days: 0,
|
|
||||||
credit_limit_cop: 0,
|
|
||||||
tg_limit: 25,
|
|
||||||
},
|
|
||||||
formula: null,
|
|
||||||
calculation_lines: [
|
|
||||||
{
|
|
||||||
kind: 'free_non_collecting',
|
|
||||||
label: 'Free plan does not collect payment',
|
|
||||||
amount_cop: 0,
|
|
||||||
source: 'customer_commercial_profiles',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Plan Free')
|
|
||||||
expect(container.textContent).toContain('Sin cobro real')
|
|
||||||
expect(container.textContent).toContain('No se recauda pago para este perfil comercial.')
|
|
||||||
expect(container.textContent).toContain('TG límite25')
|
|
||||||
expect(container.textContent).toContain('active')
|
|
||||||
expect(container.textContent).toContain('Vigente desde1/1/2026')
|
|
||||||
expect(container.textContent).not.toContain(`12 tags × ${COP(110000)} / tag / año`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders Enterprise configured custom billing terms and override source', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue({
|
|
||||||
...usage,
|
|
||||||
tier: 'enterprise',
|
|
||||||
total_annual_cop: 48000000,
|
|
||||||
total_monthly_cop: 4000000,
|
|
||||||
subtotal_annual: 48000000,
|
|
||||||
price_per_tag: 0,
|
|
||||||
commercial_profile: {
|
|
||||||
...usage.commercial_profile!,
|
|
||||||
plan: 'enterprise',
|
|
||||||
payment_method: 'purchase_order',
|
|
||||||
payment_terms_days: 45,
|
|
||||||
credit_limit_cop: 90000000,
|
|
||||||
tg_limit: 500,
|
|
||||||
},
|
|
||||||
formula: null,
|
|
||||||
calculation_lines: [
|
|
||||||
{
|
|
||||||
kind: 'enterprise_override',
|
|
||||||
label: 'Enterprise override pricing',
|
|
||||||
amount_cop: 48000000,
|
|
||||||
source: 'Contrato marco 2026 aprobado por gerencia',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Plan Enterprise')
|
|
||||||
expect(container.textContent).toContain('Términos configurados')
|
|
||||||
expect(container.textContent).toContain('45 días')
|
|
||||||
expect(container.textContent).toContain(COP(90000000))
|
|
||||||
expect(container.textContent).toContain(COP(48000000))
|
|
||||||
expect(container.textContent).toContain(COP(4000000))
|
|
||||||
expect(container.textContent).toContain('Contrato marco 2026 aprobado por gerencia')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render obsolete active-tags times Pbase messaging for profile-backed billing', async () => {
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).not.toContain(`12 tags × ${COP(110000)} / tag / año`)
|
|
||||||
expect(container.textContent).not.toContain('Total tags activos facturables')
|
|
||||||
expect(container.textContent).toContain('Fuente backend')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps historical invoice rows total-only and does not fabricate HF/LF history', async () => {
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
const invoiceHistory = container.querySelector('[data-testid="invoice-history"]')
|
|
||||||
expect(invoiceHistory?.textContent).toContain('FAC-2026-001')
|
|
||||||
expect(invoiceHistory?.textContent).toContain('9')
|
|
||||||
expect(invoiceHistory?.textContent).toContain(COP(990000))
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('Alta frecuencia')
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('Baja frecuencia')
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('HF')
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('LF')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders zero current usage from API data without demo or fallback counts', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue({
|
|
||||||
...usage,
|
|
||||||
hf_variables: 0,
|
|
||||||
lf_variables: 0,
|
|
||||||
active_tags: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
formula: {
|
|
||||||
...usage.formula!,
|
|
||||||
hf_tags: 0,
|
|
||||||
lf_tags: 0,
|
|
||||||
equivalent_tg: 0,
|
|
||||||
annual_cop: 0,
|
|
||||||
monthly_cop: 0,
|
|
||||||
effective_hf_price_cop: 0,
|
|
||||||
effective_lf_price_cop: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Alta frecuencia (HF)0')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia (LF)0')
|
|
||||||
expect(container.textContent).toContain('Total tags activos0')
|
|
||||||
expect(container.textContent).toContain('Alta frecuencia en fórmula backend0')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia con factor 0.4 TG0')
|
|
||||||
expect(container.textContent).not.toContain('25 tags')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a usage error state without stale or fabricated counts when usage fails', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockRejectedValue(new Error('usage unavailable'))
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('No se pudo cargar el uso actual de facturación.')
|
|
||||||
expect(container.textContent).not.toContain('Alta frecuencia')
|
|
||||||
expect(container.textContent).not.toContain('Total tags activos')
|
|
||||||
expect(container.textContent).not.toContain('25 tags')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getBillingUsage, getInvoices, type BillingUsage, type Invoice } from '../api/billing-api'
|
import { getBillingUsage, getInvoices, type Invoice } from '../api/billing-api'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Activity, AlertTriangle, Calendar, CreditCard, Gauge, Mail, ReceiptText, Tag, Wallet } from 'lucide-react'
|
import { CreditCard, Tag, Calendar, Mail } from 'lucide-react'
|
||||||
|
|
||||||
const COP = (amount: number) =>
|
const COP = (amount: number) =>
|
||||||
new Intl.NumberFormat('es-CO', {
|
new Intl.NumberFormat('es-CO', {
|
||||||
@@ -16,8 +16,6 @@ const tierLabel: Record<string, string> = {
|
|||||||
starter: 'Starter',
|
starter: 'Starter',
|
||||||
standard: 'Standard',
|
standard: 'Standard',
|
||||||
enterprise: 'Enterprise',
|
enterprise: 'Enterprise',
|
||||||
free: 'Free',
|
|
||||||
operative: 'Operative',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
||||||
@@ -27,40 +25,22 @@ const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceStatusConfig: Record<string, { label: string; className: string }> = {
|
const invoiceStatusConfig: Record<string, { label: string; className: string }> = {
|
||||||
pending: { label: 'Pendiente', className: 'bg-orange-500/15 text-orange-300 border-orange-400/40' },
|
pending: { label: 'Pendiente', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' },
|
||||||
sent: { label: 'Enviada', className: 'bg-sky-500/15 text-sky-300 border-sky-400/35' },
|
sent: { label: 'Enviada', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||||
paid: { label: 'Pagada', className: 'bg-emerald-500/15 text-emerald-300 border-emerald-400/35' },
|
paid: { label: 'Pagada', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
|
||||||
overdue: { label: 'Vencida', className: 'bg-red-500/15 text-red-300 border-red-400/35' },
|
overdue: { label: 'Vencida', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
|
||||||
cancelled: { label: 'Cancelada', className: 'bg-slate-500/15 text-slate-300 border-slate-400/30' },
|
cancelled: { label: 'Cancelada', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function InvoiceStatusBadge({ status }: { status: Invoice['status'] }) {
|
function InvoiceStatusBadge({ status }: { status: Invoice['status'] }) {
|
||||||
const cfg = invoiceStatusConfig[status] ?? invoiceStatusConfig.pending!
|
const cfg = invoiceStatusConfig[status] ?? invoiceStatusConfig.pending!
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${cfg.className}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${cfg.className}`}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellCard = 'border-[#2a3038] bg-[#11151a] shadow-[0_0_0_1px_rgba(255,255,255,0.02)]'
|
|
||||||
const panelCard = 'border-[#2d333c] bg-[#12161c] shadow-[0_18px_50px_rgba(0,0,0,0.18)]'
|
|
||||||
const mutedLabel = 'text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
|
|
||||||
const tableHeader = 'px-4 py-3 text-left text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
|
|
||||||
const tableCell = 'px-4 py-3 text-sm text-slate-300'
|
|
||||||
|
|
||||||
const planLabel = (plan: string) => tierLabel[plan] ?? plan
|
|
||||||
const formatDate = (value: string) => new Date(value).toLocaleDateString('es-CO', { timeZone: 'UTC' })
|
|
||||||
const formatNumber = (value: number) => new Intl.NumberFormat('es-CO', { maximumFractionDigits: 2 }).format(value)
|
|
||||||
|
|
||||||
function profilePlan(usage: BillingUsage) {
|
|
||||||
return usage.commercial_profile?.plan ?? usage.tier
|
|
||||||
}
|
|
||||||
|
|
||||||
function billingSource(usage: BillingUsage) {
|
|
||||||
return usage.commercial_profile ? 'Fuente backend · perfil comercial' : 'Legado · suscripción sin perfil comercial'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
||||||
|
|
||||||
@@ -92,284 +72,171 @@ export default function BillingPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usageQuery.isError) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64 px-6">
|
|
||||||
<div className="max-w-md rounded border border-red-500/30 bg-red-500/10 p-4 text-center">
|
|
||||||
<p className="text-red-300 font-semibold">No se pudo cargar el uso actual de facturación.</p>
|
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
|
||||||
Intenta nuevamente más tarde; no se muestran conteos hasta recibir datos reales del API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = usageQuery.data
|
const usage = usageQuery.data
|
||||||
if (!usage) return null
|
if (!usage) return null
|
||||||
|
|
||||||
const tagPercent = usage.tag_limit > 0 ? (usage.active_tags / usage.tag_limit) * 100 : 0
|
const tagPercent = usage.tag_limit > 0 ? (usage.active_tags / usage.tag_limit) * 100 : 0
|
||||||
const barColor =
|
const barColor =
|
||||||
usage.tag_limit === 0 ? 'bg-slate-600' : tagPercent > 80 ? 'bg-orange-400' : 'bg-emerald-400'
|
usage.tag_limit === 0 ? 'bg-gray-500' : tagPercent > 80 ? 'bg-amber-400' : 'bg-green-500'
|
||||||
const formattedPeriodStart = new Date(usage.period_start).toLocaleDateString('es-CO')
|
|
||||||
const formattedPeriodEnd = new Date(usage.period_end).toLocaleDateString('es-CO')
|
|
||||||
const invoices = invoicesQuery.data ?? []
|
|
||||||
const commercialProfile = usage.commercial_profile
|
|
||||||
const formula = usage.formula
|
|
||||||
const plan = profilePlan(usage)
|
|
||||||
const planName = planLabel(plan)
|
|
||||||
const primaryLine = usage.calculation_lines[0]
|
|
||||||
const effectiveHfPrice = formula?.effective_hf_price_cop ?? usage.price_per_tag
|
|
||||||
const effectiveLfPrice = formula?.effective_lf_price_cop ?? null
|
|
||||||
const displayedAnnualCop = plan === 'operative' && formula ? formula.annual_cop : usage.total_annual_cop
|
|
||||||
const displayedMonthlyCop = plan === 'operative' && formula ? formula.monthly_cop : usage.total_monthly_cop
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-[#080a0d] px-4 py-5 text-slate-100 sm:px-6 lg:px-8">
|
<div className="space-y-6 p-6">
|
||||||
<div className="mx-auto max-w-[1720px] space-y-5">
|
|
||||||
<div className="flex flex-col gap-3 border-b border-[#242a32] pb-4 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 inline-flex rounded-md border border-orange-500/35 bg-orange-500/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-orange-300">
|
<h1 className="text-2xl font-black text-white">Facturación & Plan</h1>
|
||||||
Consola de facturación
|
<p className="text-gray-400 text-sm mt-1">{usage.project_name}</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold tracking-[-0.02em] text-white sm:text-3xl">Facturación</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">Consumo y facturación por proyecto · {usage.project_name}</p>
|
{/* Plan summary */}
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
{/* Plan card */}
|
||||||
<Badge variant={tierVariant[plan] ?? 'secondary'} className="border border-orange-400/25 bg-orange-500/10 px-3 py-1 text-orange-200">
|
<Card className="bg-gray-900 border-gray-700 col-span-1">
|
||||||
Plan {planName}
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
Plan Activo
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={tierVariant[usage.tier] ?? 'secondary'} className="text-sm px-3 py-1">
|
||||||
|
{tierLabel[usage.tier] ?? usage.tier}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="rounded-md border border-emerald-400/25 bg-emerald-500/10 px-3 py-1 font-semibold uppercase tracking-[0.14em] text-emerald-300">
|
<span className={`text-xs px-2 py-0.5 rounded border ${
|
||||||
|
usage.subscription_status === 'active'
|
||||||
|
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||||
|
: usage.subscription_status === 'trial'
|
||||||
|
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||||
|
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
{usage.subscription_status}
|
{usage.subscription_status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<Card className={shellCard}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className={mutedLabel}>{plan === 'free' ? 'Sin cobro real' : 'Costo anual actual'}</p>
|
|
||||||
<p className="mt-3 text-2xl font-semibold leading-none tracking-[-0.025em] text-white">{COP(displayedAnnualCop)}</p>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">{billingSource(usage)}</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-lg border border-orange-400/20 bg-orange-500/10 p-2 text-orange-300">
|
|
||||||
<ReceiptText className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className={shellCard}>
|
{/* Tags card */}
|
||||||
<CardContent className="p-4">
|
<Card className="bg-gray-900 border-gray-700 col-span-1">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<CardHeader className="pb-2">
|
||||||
<div>
|
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
|
||||||
<p className={mutedLabel}>Estimado mensual</p>
|
|
||||||
<p className="mt-3 text-2xl font-semibold leading-none tracking-[-0.025em] text-white">{COP(displayedMonthlyCop)}</p>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">{plan === 'free' ? 'No se recauda pago' : 'Valor devuelto por backend'}</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-lg border border-sky-400/20 bg-sky-500/10 p-2 text-sky-300">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className={shellCard}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className={mutedLabel}>Tags activos</p>
|
|
||||||
<p className="mt-3 text-2xl font-semibold leading-none tracking-[-0.025em] text-white">
|
|
||||||
{usage.active_tags}
|
|
||||||
{usage.tag_limit > 0 && <span className="text-sm font-semibold text-slate-500"> / {usage.tag_limit}</span>}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">{usage.hf_variables} HF · {usage.lf_variables} LF</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-lg border border-emerald-400/20 bg-emerald-500/10 p-2 text-emerald-300">
|
|
||||||
<Tag className="h-4 w-4" />
|
<Tag className="h-4 w-4" />
|
||||||
</span>
|
Tags Activos
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className={shellCard}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className={mutedLabel}>Periodo vigente</p>
|
|
||||||
<p className="mt-3 text-lg font-semibold leading-none tracking-[-0.01em] text-white">{formattedPeriodEnd}</p>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">Desde {formattedPeriodStart}</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-lg border border-amber-400/20 bg-amber-500/10 p-2 text-amber-300">
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<Card className={panelCard}>
|
|
||||||
<CardHeader className="border-b border-[#252b33] px-4 py-3">
|
|
||||||
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
|
|
||||||
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
|
|
||||||
<ReceiptText className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Desglose comercial
|
|
||||||
<span className="mt-0.5 block text-xs font-medium text-slate-500">{usage.project_name} · Plan {planName} · {billingSource(usage)}</span>
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent>
|
||||||
{!commercialProfile && (
|
<p className="text-2xl font-black text-white">
|
||||||
<div className="m-4 flex gap-3 rounded-lg border border-amber-400/30 bg-amber-500/10 p-3 text-sm text-amber-100">
|
{usage.active_tags}
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
{usage.tag_limit > 0 && (
|
||||||
<div>
|
<span className="text-sm text-gray-400 font-normal"> / {usage.tag_limit}</span>
|
||||||
<p className="font-semibold">Facturación legacy sin perfil comercial</p>
|
)}
|
||||||
<p className="mt-1 text-xs text-amber-100/75">Este proyecto no tiene perfil comercial activo; los importes vienen de la suscripción heredada.</p>
|
</p>
|
||||||
</div>
|
{usage.tag_limit > 0 && (
|
||||||
|
<div className="mt-2 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${barColor}`}
|
||||||
|
style={{ width: `${Math.min(tagPercent, 100)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="overflow-x-auto">
|
</CardContent>
|
||||||
<table className="w-full min-w-[760px] border-collapse text-sm">
|
</Card>
|
||||||
<thead className="bg-[#0d1116]">
|
|
||||||
<tr className="border-b border-[#2b313a]">
|
{/* Period card */}
|
||||||
<th className={tableHeader}>Concepto</th>
|
<Card className="bg-gray-900 border-gray-700 col-span-1">
|
||||||
<th className={tableHeader}>Detalle</th>
|
<CardHeader className="pb-2">
|
||||||
<th className={`${tableHeader} text-right`}>Cant.</th>
|
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
|
||||||
<th className={`${tableHeader} text-right`}>Unitario</th>
|
<Calendar className="h-4 w-4" />
|
||||||
<th className={`${tableHeader} text-right`}>Total</th>
|
Período de Facturación
|
||||||
</tr>
|
</CardTitle>
|
||||||
</thead>
|
</CardHeader>
|
||||||
<tbody className="divide-y divide-[#20262e]">
|
<CardContent>
|
||||||
{commercialProfile && plan === 'free' && (
|
<p className="text-xs text-gray-300">
|
||||||
<tr className="hover:bg-white/[0.025]">
|
Desde{' '}
|
||||||
<td className={`${tableCell} font-semibold text-white`}>Sin cobro real</td>
|
<span className="text-white font-semibold">
|
||||||
<td className={tableCell}>No se recauda pago para este perfil comercial.</td>
|
{new Date(usage.period_start).toLocaleDateString('es-CO')}
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>{usage.active_tags}</td>
|
</span>
|
||||||
<td className={`${tableCell} text-right font-mono text-slate-500`}>{COP(0)}</td>
|
</p>
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-emerald-300`}>{COP(0)}</td>
|
<p className="text-xs text-gray-300 mt-1">
|
||||||
</tr>
|
Hasta{' '}
|
||||||
)}
|
<span className="text-white font-semibold">
|
||||||
{commercialProfile && plan === 'operative' && formula && (
|
{new Date(usage.period_end).toLocaleDateString('es-CO')}
|
||||||
<>
|
</span>
|
||||||
<tr className="hover:bg-white/[0.025]">
|
</p>
|
||||||
<td className={`${tableCell} font-semibold text-white`}>HF tags</td>
|
</CardContent>
|
||||||
<td className={tableCell}>Alta frecuencia en fórmula backend</td>
|
</Card>
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>{formula.hf_tags}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono text-slate-300`}>{COP(effectiveHfPrice)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>{COP(effectiveHfPrice * formula.hf_tags)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="hover:bg-white/[0.025]">
|
|
||||||
<td className={`${tableCell} font-semibold text-white`}>LF tags</td>
|
|
||||||
<td className={tableCell}>Baja frecuencia con factor 0.4 TG</td>
|
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>{formula.lf_tags}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono text-slate-300`}>{effectiveLfPrice == null ? 'No informado' : COP(effectiveLfPrice)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>Incluido en V</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="bg-orange-500/[0.035]">
|
|
||||||
<td className={`${tableCell} font-semibold text-orange-100`}>V^-k</td>
|
|
||||||
<td className={tableCell}>V = HF + (LF × 0.4) · Pbase · k</td>
|
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>TG equivalente {formatNumber(formula.equivalent_tg)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono text-orange-200`}>Pbase {COP(formula.pbase_cop)} · k {formatNumber(formula.k)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-orange-200`}>{COP(formula.annual_cop)}</td>
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{commercialProfile && plan === 'enterprise' && (
|
|
||||||
<tr className="hover:bg-white/[0.025]">
|
|
||||||
<td className={`${tableCell} font-semibold text-white`}>Términos configurados</td>
|
|
||||||
<td className={tableCell}>{primaryLine?.source ?? 'Override Enterprise aprobado'}</td>
|
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>1</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono text-slate-300`}>{COP(usage.total_annual_cop)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>{COP(usage.total_annual_cop)}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!commercialProfile && (
|
|
||||||
<tr className="hover:bg-white/[0.025]">
|
|
||||||
<td className={`${tableCell} font-semibold text-white`}>Suscripción legacy</td>
|
|
||||||
<td className={tableCell}>Importe anual heredado de backend</td>
|
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>{usage.active_tags}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono text-slate-300`}>{COP(usage.price_per_tag)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>{COP(usage.subtotal_annual)}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#2b313a] bg-[#0d1116] px-4 py-4">
|
{/* Cost breakdown */}
|
||||||
<div className="ml-auto w-full max-w-md space-y-2">
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<CardHeader>
|
||||||
<span className="text-slate-400">Subtotal anual</span>
|
<CardTitle className="text-white font-bold">Resumen de Costos</CardTitle>
|
||||||
<span className="font-mono font-semibold text-white">{COP(displayedAnnualCop)}</span>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{usage.active_tags} tags × {COP(usage.price_per_tag)} / tag / año
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-semibold">{COP(usage.subtotal_annual)}</span>
|
||||||
</div>
|
</div>
|
||||||
{usage.minimum_applied && (
|
{usage.minimum_applied && (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex justify-between items-center text-sm bg-amber-500/10 rounded px-3 py-2 border border-amber-500/20">
|
||||||
<span className="text-orange-300">Mínimo aplicado</span>
|
<span className="text-amber-400">Cargo mínimo aplicado</span>
|
||||||
<span className="font-mono font-semibold text-orange-300">{COP(usage.min_annual_fee)}</span>
|
<span className="text-amber-400 font-semibold">{COP(usage.min_annual_fee)} / año</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between border-t border-[#303741] pt-3">
|
<div className="border-t border-gray-700 pt-3 flex justify-between items-center">
|
||||||
<span className="text-base font-semibold text-white">Total anual</span>
|
<span className="text-white font-bold">Total anual</span>
|
||||||
<span className="font-mono text-2xl font-semibold tracking-[-0.025em] text-orange-400">{COP(displayedAnnualCop)}</span>
|
<span className="text-2xl font-black text-amber-400">{COP(usage.total_annual_cop)}</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-slate-500">Estimado mensual</span>
|
|
||||||
<span className="font-mono font-semibold text-slate-300">{COP(displayedMonthlyCop)} / mes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-400">Estimado mensual</span>
|
||||||
|
<span className="text-gray-300 font-semibold">{COP(usage.total_monthly_cop)} / mes</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className={panelCard}>
|
{/* Invoice history */}
|
||||||
<CardHeader className="border-b border-[#252b33] px-4 py-3">
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
|
<CardHeader>
|
||||||
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
|
<CardTitle className="text-white font-bold">Historial de Facturas</CardTitle>
|
||||||
<CreditCard className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Historial de facturas
|
|
||||||
<span className="mt-0.5 block text-xs font-medium text-slate-500">{invoices.length} factura(s) · {usage.project_name}</span>
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0" data-testid="invoice-history">
|
<CardContent>
|
||||||
{invoicesQuery.isLoading ? (
|
{invoicesQuery.isLoading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="py-8 flex justify-center">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-400 border-t-transparent" />
|
<div className="animate-spin h-6 w-6 border-2 border-amber-400 border-t-transparent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
) : invoices.length === 0 ? (
|
) : invoicesQuery.data?.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-slate-500">Sin facturas generadas aún.</p>
|
<p className="text-gray-500 text-sm text-center py-6">Sin facturas generadas aún.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[760px] text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-[#0d1116]">
|
<thead>
|
||||||
<tr className="border-b border-[#2b313a]">
|
<tr className="text-gray-400 border-b border-gray-700">
|
||||||
<th className={tableHeader}>Factura</th>
|
<th className="text-left py-2 pr-4">Factura</th>
|
||||||
<th className={tableHeader}>Periodo</th>
|
<th className="text-left py-2 pr-4">Período</th>
|
||||||
<th className={`${tableHeader} text-right`}>Tags</th>
|
<th className="text-right py-2 pr-4">Tags</th>
|
||||||
<th className={`${tableHeader} text-right`}>Total</th>
|
<th className="text-right py-2 pr-4">Total</th>
|
||||||
<th className={tableHeader}>Estado</th>
|
<th className="text-left py-2 pr-4">Estado</th>
|
||||||
<th className={tableHeader}>Emitida</th>
|
<th className="text-left py-2">Fecha</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#20262e]">
|
<tbody className="divide-y divide-gray-800">
|
||||||
{invoices.map((inv) => (
|
{invoicesQuery.data?.map((inv) => (
|
||||||
<tr key={inv.id} className="text-slate-300 transition-colors hover:bg-white/[0.025]">
|
<tr key={inv.id} className="text-gray-300 hover:bg-gray-800/50">
|
||||||
<td className="px-4 py-3 font-mono text-xs font-semibold text-white">{inv.invoice_number}</td>
|
<td className="py-2 pr-4 font-mono text-xs">{inv.invoice_number}</td>
|
||||||
<td className="px-4 py-3 text-xs text-slate-400">
|
<td className="py-2 pr-4 text-xs">
|
||||||
{new Date(inv.period_start).toLocaleDateString('es-CO')} - {new Date(inv.period_end).toLocaleDateString('es-CO')}
|
{new Date(inv.period_start).toLocaleDateString('es-CO')} –{' '}
|
||||||
|
{new Date(inv.period_end).toLocaleDateString('es-CO')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right">{inv.active_tags}</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-semibold text-white">
|
||||||
|
{COP(inv.total_cop)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<InvoiceStatusBadge status={inv.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-gray-400">
|
||||||
|
{new Date(inv.created_at).toLocaleDateString('es-CO')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-sm text-slate-200">{inv.active_tags}</td>
|
|
||||||
<td className="px-4 py-3 text-right font-mono text-sm font-semibold text-white">{COP(inv.total_cop)}</td>
|
|
||||||
<td className="px-4 py-3"><InvoiceStatusBadge status={inv.status} /></td>
|
|
||||||
<td className="px-4 py-3 text-xs text-slate-500">{new Date(inv.created_at).toLocaleDateString('es-CO')}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -378,112 +245,22 @@ export default function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="space-y-5">
|
{/* Contact card */}
|
||||||
<Card className={panelCard}>
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
<CardHeader className="border-b border-[#252b33] px-4 py-3">
|
<CardContent className="pt-4 flex items-center gap-3">
|
||||||
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
|
<Mail className="h-5 w-5 text-amber-400 flex-shrink-0" />
|
||||||
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
|
|
||||||
<Gauge className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Uso actual
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 p-4">
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-2 py-3">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Alta frecuencia (HF)</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">{usage.hf_variables}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-2 py-3">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Baja frecuencia (LF)</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">{usage.lf_variables}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-2 py-3">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Total tags activos</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">{usage.active_tags}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{usage.tag_limit > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-xs font-semibold text-slate-400">
|
|
||||||
<span>Capacidad del plan</span>
|
|
||||||
<span className="font-mono text-white">{usage.active_tags} / {usage.tag_limit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-[#242a31]">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full shadow-[0_0_18px_rgba(251,146,60,0.35)] transition-all ${barColor}`}
|
|
||||||
style={{ width: `${Math.min(tagPercent, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-3 py-2 text-xs text-slate-400">
|
|
||||||
Renovación y corte del periodo: <span className="font-semibold text-white">{formattedPeriodEnd}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className={panelCard}>
|
|
||||||
<CardHeader className="border-b border-[#252b33] px-4 py-3">
|
|
||||||
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
|
|
||||||
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
|
|
||||||
<Wallet className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Método de pago
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 p-4">
|
|
||||||
{commercialProfile && (
|
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Perfil comercial</p>
|
|
||||||
<span className="rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-300">
|
|
||||||
{commercialProfile.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-500">Términos</p>
|
<p className="text-sm text-gray-400">Para facturación y preguntas sobre tu plan:</p>
|
||||||
<p className="mt-1 font-semibold text-white">{commercialProfile.payment_terms_days ?? 0} días</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-500">TG límite</p>
|
|
||||||
<p className="mt-1 font-semibold text-white">{commercialProfile.tg_limit ?? 'No definido'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-500">Crédito</p>
|
|
||||||
<p className="mt-1 font-semibold text-white">{COP(commercialProfile.credit_limit_cop ?? 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-500">Vigente desde</p>
|
|
||||||
<p className="mt-1 font-semibold text-white">{formatDate(commercialProfile.effective_from)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!commercialProfile.profile_complete && (
|
|
||||||
<p className="mt-3 rounded-md border border-amber-400/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-100">
|
|
||||||
Perfil incompleto: {commercialProfile.missing_fields.join(', ') || 'faltan campos requeridos'}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] p-4">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Gestión comercial</p>
|
|
||||||
<p className="mt-2 text-sm font-semibold text-white">Método gestionado por facturación OmniOil</p>
|
|
||||||
<a
|
<a
|
||||||
href="mailto:facturacion@omnioil.app"
|
href="mailto:facturacion@omnioil.app"
|
||||||
className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-orange-300 hover:text-orange-200"
|
className="text-amber-400 hover:text-amber-300 font-semibold text-sm"
|
||||||
>
|
>
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
facturacion@omnioil.app
|
facturacion@omnioil.app
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +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'
|
||||||
import type { TelemetryFrequencyClass, TelemetrySource } from '../utils/telemetry-normalization'
|
|
||||||
|
|
||||||
export interface TelemetrySummary {
|
interface TelemetrySummary {
|
||||||
asset_id: string
|
asset_id: string
|
||||||
asset: string
|
asset: string
|
||||||
source?: TelemetrySource
|
|
||||||
frequency_class?: TelemetryFrequencyClass
|
|
||||||
last_updated: string
|
last_updated: string
|
||||||
telemetry: Record<string, unknown>
|
telemetry: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardSummaryResponse {
|
interface DashboardSummaryResponse {
|
||||||
data: TelemetrySummary[]
|
data: TelemetrySummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +17,6 @@ export interface TelemetryHistoryRecord {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
asset_id?: string
|
asset_id?: string
|
||||||
source?: TelemetrySource
|
|
||||||
frequency_class?: TelemetryFrequencyClass
|
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
values?: Record<string, unknown>
|
values?: Record<string, unknown>
|
||||||
telemetry?: Record<string, unknown>
|
telemetry?: Record<string, unknown>
|
||||||
@@ -49,8 +44,3 @@ export async function fetchAssetTelemetryHistory(assetId: string, days: number =
|
|||||||
const token = useAuthStore.getState().token
|
const token = useAuthStore.getState().token
|
||||||
return apiClient<TelemetryHistoryResponse>(`/telemetry/${assetId}/history?days=${days}&limit=${limit}`, { token, cache: 'no-store' })
|
return apiClient<TelemetryHistoryResponse>(`/telemetry/${assetId}/history?days=${days}&limit=${limit}`, { token, cache: 'no-store' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAssetTelemetry(assetId: string, limit: number = 100): Promise<any> {
|
|
||||||
const token = useAuthStore.getState().token
|
|
||||||
return apiClient<any>(`/telemetry/${assetId}?limit=${limit}`, { token })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { AlertTriangle, Bell, CheckCircle2, Gauge, Radio, Settings2 } from 'lucide-react'
|
import { AlertTriangle, Bell, CheckCircle2, Gauge, Radio, Settings2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils/cn'
|
import { cn } from '@/lib/utils/cn'
|
||||||
import type { RecentAlertItem, VariableCategoryHealthItem, WellOverviewCard } from '../utils/dashboard-view-model'
|
import type { RecentAlertItem, VariableCategoryHealthItem, WellOverviewCard } from '../utils/dashboard-view-model'
|
||||||
@@ -15,32 +14,29 @@ interface DashboardPanoramaLowerSectionProps {
|
|||||||
alertsEmptyDescription: string
|
alertsEmptyDescription: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLastSignal(timestamp?: string): string | null {
|
function buildSparklinePath(values: number[]): string {
|
||||||
if (!timestamp) return null
|
if (values.length < 2) return ''
|
||||||
|
|
||||||
const date = new Date(timestamp)
|
const min = Math.min(...values)
|
||||||
if (Number.isNaN(date.getTime())) return null
|
const max = Math.max(...values)
|
||||||
|
const range = max - min || 1
|
||||||
|
const step = 100 / (values.length - 1)
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('es', {
|
return values
|
||||||
day: '2-digit',
|
.map((value, index) => {
|
||||||
month: '2-digit',
|
const x = index * step
|
||||||
hour: '2-digit',
|
const y = 30 - ((value - min) / range) * 28
|
||||||
minute: '2-digit',
|
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
|
||||||
}).format(date)
|
})
|
||||||
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function WellCard({ well }: { well: WellOverviewCard }) {
|
function WellCard({ well }: { well: WellOverviewCard }) {
|
||||||
const lastSignal = formatLastSignal(well.lastUpdated)
|
const sparklinePath = buildSparklinePath(well.sparklineValues)
|
||||||
const statusLabel = well.isActive ? 'Activo' : 'Inactivo'
|
const statusLabel = well.isActive ? 'Activo' : 'Inactivo'
|
||||||
const hasCriticalAlerts = well.criticalCount > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<article className="rounded-[1.4rem] border border-[#2a3038] bg-[#171b20] p-4 shadow-lg shadow-black/10">
|
||||||
to={`/app/admin/wells/${well.id}`}
|
|
||||||
aria-label={`Abrir detalle del pozo ${well.name}`}
|
|
||||||
className="group block rounded-[1.4rem] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#101418]"
|
|
||||||
>
|
|
||||||
<article className="rounded-[1.4rem] border border-[#2a3038] bg-[#171b20] p-4 shadow-lg shadow-black/10 transition-colors duration-200 group-hover:border-primary/45 group-hover:bg-[#1b2027] group-hover:shadow-primary/10">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -62,28 +58,27 @@ function WellCard({ well }: { well: WellOverviewCard }) {
|
|||||||
<p className="text-[10px] uppercase tracking-[0.18em] text-[#9aa8ba]">Variables</p>
|
<p className="text-[10px] uppercase tracking-[0.18em] text-[#9aa8ba]">Variables</p>
|
||||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{well.variablesCount}</p>
|
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{well.variablesCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className="rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
|
||||||
'rounded-2xl border bg-[#15191e] p-3',
|
<p className="text-[10px] uppercase tracking-[0.18em] text-[#9aa8ba]">Críticas</p>
|
||||||
hasCriticalAlerts ? 'border-[#ff4655]/60 shadow-[0_0_24px_rgba(255,70,85,0.12)]' : 'border-[#2a3038]'
|
<p className={cn('mt-1 text-2xl font-semibold tabular-nums', well.criticalCount > 0 ? 'text-[#ff4655]' : 'text-foreground')}>
|
||||||
)}>
|
|
||||||
<p className={cn(
|
|
||||||
'text-[10px] uppercase tracking-[0.18em]',
|
|
||||||
hasCriticalAlerts ? 'text-[#ffb020]' : 'text-[#9aa8ba]'
|
|
||||||
)}>Alertas críticas</p>
|
|
||||||
<p className={cn('mt-1 text-2xl font-semibold tabular-nums', hasCriticalAlerts ? 'text-[#ff4655]' : 'text-muted-foreground')}>
|
|
||||||
{well.criticalCount}
|
{well.criticalCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
|
<div className="mt-4 rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
|
||||||
<div className="flex min-h-12 items-center justify-center gap-2 text-xs text-muted-foreground">
|
{sparklinePath ? (
|
||||||
<Radio className={cn('size-4', lastSignal ? 'text-[#27d796]' : 'text-[#64748b]')} />
|
<svg viewBox="0 0 100 32" role="img" aria-label={`Tendencia real de ${well.name}`} className="h-12 w-full overflow-visible">
|
||||||
<span>{lastSignal ? `Última señal ${lastSignal}` : 'Sin señal reciente'}</span>
|
<path d={sparklinePath} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-[#00b7ff]" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Radio className="size-4 text-[#64748b]" />
|
||||||
|
Sin señal
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function DashboardPanoramaOverview({
|
|||||||
metrics,
|
metrics,
|
||||||
}: DashboardPanoramaOverviewProps) {
|
}: DashboardPanoramaOverviewProps) {
|
||||||
const connectedWellsLabel = `${connectedWellCount} ${connectedWellCount === 1 ? 'pozo conectado' : 'pozos conectados'}`
|
const connectedWellsLabel = `${connectedWellCount} ${connectedWellCount === 1 ? 'pozo conectado' : 'pozos conectados'}`
|
||||||
const scadaLinkLabel = status.linkLabel ?? (status.label === 'Operacional' ? 'Enlace SCADA estable' : `Enlace SCADA ${status.label.toLowerCase()}`)
|
const scadaLinkLabel = status.label === 'Operacional' ? 'Enlace SCADA estable' : `Enlace SCADA ${status.label.toLowerCase()}`
|
||||||
const isOperational = status.label === 'Operacional'
|
const isOperational = status.label === 'Operacional'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +71,7 @@ export function DashboardPanoramaOverview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||||
{metrics.map((metric) => (
|
{metrics.map((metric) => (
|
||||||
<MetricCard key={metric.label} metric={metric} />
|
<MetricCard key={metric.label} metric={metric} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const TelemetryChart = memo(({
|
|||||||
getColorForUnit,
|
getColorForUnit,
|
||||||
}: TelemetryChartProps) => (
|
}: TelemetryChartProps) => (
|
||||||
<Card
|
<Card
|
||||||
className="min-h-[390px] overflow-hidden border-[#2a3038] bg-[#15191e] shadow-sm shadow-black/20"
|
className="overflow-hidden border-[#2a3038] bg-[#15191e] shadow-sm shadow-black/20"
|
||||||
aria-label={`${group.unit} telemetry panel with ${group.variables.length} ${group.variables.length === 1 ? 'variable' : 'variables'}`}
|
aria-label={`${group.unit} telemetry panel with ${group.variables.length} ${group.variables.length === 1 ? 'variable' : 'variables'}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 border-b border-[#2a3038] bg-[#171b20] px-4 py-3">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 border-b border-[#2a3038] bg-[#171b20] px-4 py-3">
|
||||||
@@ -66,7 +66,7 @@ export const TelemetryChart = memo(({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-72 p-4">
|
<div className="h-64 p-4">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={group.data}>
|
<LineChart data={group.data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { act } from 'react'
|
import { act } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Activity } from 'lucide-react'
|
import { Activity } from 'lucide-react'
|
||||||
import DashboardPage from './DashboardPage'
|
import DashboardPage from './DashboardPage'
|
||||||
@@ -14,7 +13,7 @@ import { listAssets } from '../../projects/api/wells-api'
|
|||||||
import { listDevices } from '../../projects/api/devices-api'
|
import { listDevices } from '../../projects/api/devices-api'
|
||||||
import { listVariables } from '../../projects/api/variables-api'
|
import { listVariables } from '../../projects/api/variables-api'
|
||||||
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
||||||
import { deduceUnit, getAssetProjectId, getTelemetryFrequencyClass, getTelemetrySource, normalizeTelemetry } from '../utils/telemetry-normalization'
|
import { deduceUnit, getAssetProjectId, normalizeTelemetry } from '../utils/telemetry-normalization'
|
||||||
import {
|
import {
|
||||||
buildAssetOptions,
|
buildAssetOptions,
|
||||||
buildDynamicStats,
|
buildDynamicStats,
|
||||||
@@ -63,14 +62,6 @@ async function flushEffects() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboardPage() {
|
|
||||||
return (
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/dashboard']}>
|
|
||||||
<DashboardPage />
|
|
||||||
</MemoryRouter>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DashboardPage well selector', () => {
|
describe('DashboardPage well selector', () => {
|
||||||
let container: HTMLDivElement
|
let container: HTMLDivElement
|
||||||
let root: ReturnType<typeof createRoot>
|
let root: ReturnType<typeof createRoot>
|
||||||
@@ -134,7 +125,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
|
|
||||||
it('lists created POZO assets and fetches selected well telemetry by UUID', async () => {
|
it('lists created POZO assets and fetches selected well telemetry by UUID', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -180,7 +171,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -199,7 +190,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -238,7 +229,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -260,7 +251,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -285,10 +276,12 @@ describe('DashboardPage well selector', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
expect(listAssets).toHaveBeenCalledWith('project-1')
|
expect(listAssets).toHaveBeenCalledWith('project-1')
|
||||||
})
|
})
|
||||||
@@ -333,10 +326,12 @@ describe('DashboardPage well selector', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
sockets[0]?.onmessage?.({
|
sockets[0]?.onmessage?.({
|
||||||
@@ -358,7 +353,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
|
|
||||||
it('resets selection and shows a non-interactive status when created well loading fails', async () => {
|
it('resets selection and shows a non-interactive status when created well loading fails', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -392,7 +387,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
vi.mocked(listAssets).mockResolvedValue([])
|
vi.mocked(listAssets).mockResolvedValue([])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
@@ -416,13 +411,6 @@ describe('DashboardPage well selector', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
vi.mocked(listDevices).mockResolvedValue([
|
|
||||||
{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(listVariables).mockResolvedValue([
|
|
||||||
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'configured_pressure', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-2', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'configured_temp', unit: '°C', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(getAlarms).mockResolvedValue([
|
vi.mocked(getAlarms).mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'alarm-1',
|
id: 'alarm-1',
|
||||||
@@ -440,22 +428,16 @@ describe('DashboardPage well selector', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Vista general por pozo')
|
expect(container.textContent).toContain('Vista general por pozo')
|
||||||
expect(container.textContent).toContain('Pozo Norte')
|
expect(container.textContent).toContain('Pozo Norte')
|
||||||
expect(container.textContent).toContain('PZ-01')
|
expect(container.textContent).toContain('PZ-01')
|
||||||
expect(container.textContent).toContain('Variables2')
|
expect(container.textContent).toContain('Variables2')
|
||||||
expect(container.textContent).toContain('Alertas críticas1')
|
expect(container.textContent).toContain('Críticas1')
|
||||||
expect(container.textContent).not.toContain('Críticas')
|
expect(container.textContent).toContain('Sin señal')
|
||||||
expect(container.textContent).toContain('Última señal')
|
|
||||||
expect(container.querySelector('svg[aria-label^="Tendencia"]')).toBeNull()
|
|
||||||
const wellLink = container.querySelector('a[aria-label="Abrir detalle del pozo Pozo Norte"]') as HTMLAnchorElement | null
|
|
||||||
expect(wellLink).not.toBeNull()
|
|
||||||
expect(wellLink?.getAttribute('href')).toBe('/app/admin/wells/asset-uuid-pozo-1')
|
|
||||||
expect(container.textContent).toContain('Alertas recientes')
|
expect(container.textContent).toContain('Alertas recientes')
|
||||||
expect(container.textContent).toContain('pressure_psi')
|
expect(container.textContent).toContain('pressure_psi')
|
||||||
expect(container.textContent).toContain('Presión sobre el máximo')
|
expect(container.textContent).toContain('Presión sobre el máximo')
|
||||||
@@ -472,13 +454,6 @@ describe('DashboardPage well selector', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
vi.mocked(listDevices).mockResolvedValue([
|
|
||||||
{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(listVariables).mockResolvedValue([
|
|
||||||
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'configured_pressure', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-2', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'configured_temperature', unit: '°C', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(getAlarms).mockResolvedValue([
|
vi.mocked(getAlarms).mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'alarm-1',
|
id: 'alarm-1',
|
||||||
@@ -496,23 +471,22 @@ describe('DashboardPage well selector', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Telemetría activa · 1 pozo conectado · latencia 0 ms')
|
expect(container.textContent).toContain('Telemetría activa · 1 pozo conectado · latencia 0 ms')
|
||||||
expect(container.textContent).toContain('2 señales recibidas')
|
expect(container.textContent).toContain('2 señales recibidas')
|
||||||
expect(container.textContent).toContain('Pozos1en operación')
|
expect(container.textContent).toContain('Pozos1en operación')
|
||||||
expect(container.textContent).toContain('Variables2monitoreadas')
|
expect(container.textContent).toContain('Variables2monitoreadas')
|
||||||
expect(container.textContent).toContain('Alertas1activas')
|
expect(container.textContent).toContain('Alertas1activas')
|
||||||
expect(container.textContent).toContain('Alta frec.0señales SCADA')
|
expect(container.textContent).toContain('Alta frec.—sin fuente configurada')
|
||||||
expect(container.textContent).toContain('Baja frec.0señales PWA/manual')
|
expect(container.textContent).toContain('Baja frec.—sin fuente configurada')
|
||||||
expect(container.textContent).not.toContain('Con falla')
|
expect(container.textContent).toContain('Con falla—sin fuente configurada')
|
||||||
expect(container.textContent).not.toContain('Producción')
|
expect(container.textContent).toContain('Producción—sin fuente configurada')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts configured listVariables inventory for the Panorama Variables KPI', async () => {
|
it('sums repeated per-well variable aliases for the Panorama Variables KPI', async () => {
|
||||||
vi.mocked(listAssets).mockResolvedValue([
|
vi.mocked(listAssets).mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'asset-uuid-pozo-1',
|
id: 'asset-uuid-pozo-1',
|
||||||
@@ -555,36 +529,11 @@ describe('DashboardPage well selector', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
vi.mocked(listDevices).mockImplementation(async (assetId) => [
|
|
||||||
{ id: `${assetId}-device`, asset_id: assetId, name: 'PLC', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(listVariables).mockImplementation(async (deviceId) => {
|
|
||||||
const aliases = deviceId.includes('pozo-1')
|
|
||||||
? ['pressure_psi', 'temp_c']
|
|
||||||
: ['pressure_psi', 'caudal_bpd']
|
|
||||||
|
|
||||||
return aliases.map((alias, index) => ({
|
|
||||||
id: `${deviceId}-var-${index}`,
|
|
||||||
device_id: deviceId,
|
|
||||||
address: `4000${index + 1}`,
|
|
||||||
data_type: 'float32' as const,
|
|
||||||
alias,
|
|
||||||
unit: 'RAW',
|
|
||||||
polling_interval_ms: 5000,
|
|
||||||
alarm_enabled: true,
|
|
||||||
byte_order: 'BE' as const,
|
|
||||||
metadata: {},
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-22T00:00:00Z',
|
|
||||||
updated_at: '2026-06-22T00:00:00Z',
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('3 señales recibidas')
|
expect(container.textContent).toContain('3 señales recibidas')
|
||||||
expect(container.textContent).toContain('Variables4monitoreadas')
|
expect(container.textContent).toContain('Variables4monitoreadas')
|
||||||
@@ -633,36 +582,11 @@ describe('DashboardPage well selector', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
vi.mocked(listDevices).mockImplementation(async (assetId) => [
|
|
||||||
{ id: `${assetId}-device`, asset_id: assetId, name: 'PLC', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(listVariables).mockImplementation(async (deviceId) => {
|
|
||||||
const aliases = deviceId.includes('pozo-1')
|
|
||||||
? ['pressure_psi', 'temp_c', 'motor_amp']
|
|
||||||
: ['configured_pressure', 'configured_flow']
|
|
||||||
|
|
||||||
return aliases.map((alias, index) => ({
|
|
||||||
id: `${deviceId}-var-${index}`,
|
|
||||||
device_id: deviceId,
|
|
||||||
address: `4000${index + 1}`,
|
|
||||||
data_type: 'float32' as const,
|
|
||||||
alias,
|
|
||||||
unit: 'RAW',
|
|
||||||
polling_interval_ms: 5000,
|
|
||||||
alarm_enabled: true,
|
|
||||||
byte_order: 'BE' as const,
|
|
||||||
metadata: {},
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-22T00:00:00Z',
|
|
||||||
updated_at: '2026-06-22T00:00:00Z',
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const trigger = container.querySelector('button[aria-haspopup="listbox"]') as HTMLButtonElement
|
const trigger = container.querySelector('button[aria-haspopup="listbox"]') as HTMLButtonElement
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -681,74 +605,6 @@ describe('DashboardPage well selector', () => {
|
|||||||
expect(container.textContent).toContain('Variables2monitoreadas')
|
expect(container.textContent).toContain('Variables2monitoreadas')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not change the Panorama Variables KPI when telemetry changes but inventory does not', async () => {
|
|
||||||
const sockets: Array<{
|
|
||||||
onmessage: ((event: MessageEvent<string>) => void) | null
|
|
||||||
onopen: (() => void) | null
|
|
||||||
onclose: (() => void) | null
|
|
||||||
onerror: (() => void) | null
|
|
||||||
close: ReturnType<typeof vi.fn>
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
class FakeWebSocket {
|
|
||||||
onmessage: ((event: MessageEvent<string>) => void) | null = null
|
|
||||||
onopen: (() => void) | null = null
|
|
||||||
onclose: (() => void) | null = null
|
|
||||||
onerror: (() => void) | null = null
|
|
||||||
close = vi.fn()
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
sockets.push(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
|
||||||
vi.mocked(createTelemetryWsTicket).mockResolvedValue({ ticket: 'ticket-1', expires_in: 300 })
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
asset_id: 'asset-uuid-pozo-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { pressure_psi: 520 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
vi.mocked(listDevices).mockResolvedValue([
|
|
||||||
{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
vi.mocked(listVariables).mockResolvedValue([
|
|
||||||
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'configured_pressure', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-2', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'configured_temp', unit: '°C', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(renderDashboardPage())
|
|
||||||
})
|
|
||||||
await flushEffects()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Variables2monitoreadas')
|
|
||||||
expect(container.textContent).toContain('1 señales recibidas')
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
sockets[0]?.onmessage?.({
|
|
||||||
data: JSON.stringify({
|
|
||||||
asset_id: 'asset-uuid-pozo-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
pressure_psi: 525,
|
|
||||||
temp_c: 84,
|
|
||||||
motor_amp: 22,
|
|
||||||
vibration: 3,
|
|
||||||
}),
|
|
||||||
} as MessageEvent<string>)
|
|
||||||
})
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Variables2monitoreadas')
|
|
||||||
expect(container.textContent).toContain('4 señales recibidas')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders an honest category-health empty state when real variables have no category metadata', async () => {
|
it('renders an honest category-health empty state when real variables have no category metadata', async () => {
|
||||||
vi.mocked(listDevices).mockResolvedValue([{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' }])
|
vi.mocked(listDevices).mockResolvedValue([{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' }])
|
||||||
vi.mocked(listVariables).mockResolvedValue([
|
vi.mocked(listVariables).mockResolvedValue([
|
||||||
@@ -756,7 +612,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
@@ -785,7 +641,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(renderDashboardPage())
|
root.render(<DashboardPage />)
|
||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
@@ -877,7 +733,7 @@ describe('dashboard view-model helpers', () => {
|
|||||||
expect(getUnitBackgroundClass('RAW')).toBe('bg-primary/10')
|
expect(getUnitBackgroundClass('RAW')).toBe('bg-primary/10')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('builds Panorama metrics from real assets and omits unsupported business metrics', () => {
|
it('builds Panorama metrics from real assets and marks unsupported business metrics as unavailable', () => {
|
||||||
const assets = [
|
const assets = [
|
||||||
{
|
{
|
||||||
asset_id: 'asset-1',
|
asset_id: 'asset-1',
|
||||||
@@ -930,117 +786,14 @@ describe('dashboard view-model helpers', () => {
|
|||||||
{ label: 'Pozos', value: 2, sub: 'en operación' },
|
{ label: 'Pozos', value: 2, sub: 'en operación' },
|
||||||
{ label: 'Variables', value: 4, sub: 'monitoreadas' },
|
{ label: 'Variables', value: 4, sub: 'monitoreadas' },
|
||||||
{ label: 'Alertas', value: 1, sub: 'activas', accent: 'text-[#ffb020]' },
|
{ label: 'Alertas', value: 1, sub: 'activas', accent: 'text-[#ffb020]' },
|
||||||
{ label: 'Alta frec.', value: 0, sub: 'señales SCADA', accent: 'text-[#00b7ff]' },
|
{ label: 'Alta frec.', value: null, sub: 'sin fuente configurada', accent: 'text-[#00b7ff]' },
|
||||||
{ label: 'Baja frec.', value: 0, sub: 'señales PWA/manual', accent: 'text-[#27d796]' },
|
{ label: 'Baja frec.', value: null, sub: 'sin fuente configurada', accent: 'text-[#27d796]' },
|
||||||
|
{ label: 'Con falla', value: null, sub: 'sin fuente configurada', accent: 'text-[#ff4655]' },
|
||||||
|
{ label: 'Producción', value: null, sub: 'sin fuente configurada', accent: 'text-primary' },
|
||||||
])
|
])
|
||||||
expect(summary.metrics.map((metric) => metric.label)).not.toEqual(expect.arrayContaining(['Con falla', 'Producción']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('counts Panorama high and low frequency metrics from authoritative telemetry origin', () => {
|
it('builds well overview and recent alerts from associated real records only', () => {
|
||||||
const assets = [
|
|
||||||
{
|
|
||||||
asset_id: 'asset-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'SCADA' as const,
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { pressure_psi: 515, temp_c: 80 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
asset_id: 'asset-2',
|
|
||||||
asset: 'Pozo Sur',
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { source: 'PWA', caudal_bpd: 120, bsw_pct: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
asset_id: 'asset-3',
|
|
||||||
asset: 'Pozo Centro',
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { frequency_class: 'LF', gas_mcf: 25 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const summary = buildPanoramaSummary(assets, [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }])
|
|
||||||
|
|
||||||
expect(summary.signalCount).toBe(5)
|
|
||||||
expect(summary.metrics.map(({ icon: _icon, ...metric }) => metric)).toEqual(expect.arrayContaining([
|
|
||||||
{ label: 'Alta frec.', value: 2, sub: 'señales SCADA', accent: 'text-[#00b7ff]' },
|
|
||||||
{ label: 'Baja frec.', value: 3, sub: 'señales PWA/manual', accent: 'text-[#27d796]' },
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps unknown telemetry origins out of Panorama HF/LF buckets', () => {
|
|
||||||
const summary = buildPanoramaSummary([
|
|
||||||
{
|
|
||||||
asset_id: 'asset-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { pressure_psi: 515, temp_c: 80 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
asset_id: 'asset-2',
|
|
||||||
asset: 'Pozo Sur',
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { source: 'satellite', caudal_bpd: 120 },
|
|
||||||
},
|
|
||||||
], [{ id: 'asset-1' }, { id: 'asset-2' }])
|
|
||||||
|
|
||||||
expect(summary.signalCount).toBe(3)
|
|
||||||
expect(summary.metrics.map(({ icon: _icon, ...metric }) => metric)).toEqual(expect.arrayContaining([
|
|
||||||
{ label: 'Alta frec.', value: 0, sub: 'señales SCADA', accent: 'text-[#00b7ff]' },
|
|
||||||
{ label: 'Baja frec.', value: 0, sub: 'señales PWA/manual', accent: 'text-[#27d796]' },
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('normalizes telemetry origin metadata from top-level and nested payload fields', () => {
|
|
||||||
const topLevel = {
|
|
||||||
asset_id: 'asset-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'SCADA' as const,
|
|
||||||
frequency_class: 'HF' as const,
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { pressure_psi: 515 },
|
|
||||||
}
|
|
||||||
const nested = {
|
|
||||||
asset_id: 'asset-2',
|
|
||||||
asset: 'Pozo Sur',
|
|
||||||
last_updated: '2026-06-22T00:00:00Z',
|
|
||||||
telemetry: { source: 'manual', frequency_class: 'lf', caudal_bpd: 120 },
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(getTelemetrySource(topLevel)).toBe('SCADA')
|
|
||||||
expect(getTelemetryFrequencyClass(topLevel)).toBe('HF')
|
|
||||||
expect(getTelemetrySource(nested)).toBe('MANUAL')
|
|
||||||
expect(getTelemetryFrequencyClass(nested)).toBe('LF')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not let fresh LF telemetry make global HF compliance stable', () => {
|
|
||||||
const now = new Date('2026-06-22T12:00:00Z')
|
|
||||||
const history = [
|
|
||||||
{
|
|
||||||
time: '2026-06-22T11:59:50Z',
|
|
||||||
data: { source: 'PWA', caudal_bpd: 120 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
time: '2026-06-22T11:00:00Z',
|
|
||||||
data: { source: 'SCADA', pressure_psi: 515 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
expect(getComplianceStatus(history, 'all', now, 0, 'HF').isStable).toBe(false)
|
|
||||||
expect(getDashboardStatus(true, false, 'HF').label).toBe('Señal retrasada')
|
|
||||||
|
|
||||||
const freshScadaHistory = [
|
|
||||||
...history,
|
|
||||||
{
|
|
||||||
time: '2026-06-22T11:59:55Z',
|
|
||||||
data: { source: 'SCADA', pressure_psi: 520 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
expect(getComplianceStatus(freshScadaHistory, 'all', now, 0, 'HF').isStable).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('builds well overview counts from configured variables and active critical alarms', () => {
|
|
||||||
const wells = [
|
const wells = [
|
||||||
{ id: 'asset-1', code: 'PZ-01', name: 'Pozo Norte', project_id: 'project-1', is_active: true },
|
{ id: 'asset-1', code: 'PZ-01', name: 'Pozo Norte', project_id: 'project-1', is_active: true },
|
||||||
{ id: 'asset-2', code: 'PZ-02', name: 'Pozo Sur', project_id: 'project-1', is_active: false },
|
{ id: 'asset-2', code: 'PZ-02', name: 'Pozo Sur', project_id: 'project-1', is_active: false },
|
||||||
@@ -1059,44 +812,24 @@ describe('dashboard view-model helpers', () => {
|
|||||||
{
|
{
|
||||||
id: 'alarm-2',
|
id: 'alarm-2',
|
||||||
project_id: 'project-1',
|
project_id: 'project-1',
|
||||||
asset_id: 'asset-1',
|
asset_id: 'asset-2',
|
||||||
variable_alias: 'temp_c',
|
variable_alias: 'temp_c',
|
||||||
alarm_type: 'BELOW_MIN',
|
alarm_type: 'BELOW_MIN',
|
||||||
message: 'Temperature low',
|
message: 'Temperature low',
|
||||||
acknowledged: false,
|
|
||||||
timestamp: '2026-06-22T11:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alarm-3',
|
|
||||||
project_id: 'project-1',
|
|
||||||
asset_id: 'asset-1',
|
|
||||||
variable_alias: 'agent',
|
|
||||||
alarm_type: 'AGENT_OFFLINE',
|
|
||||||
message: 'Agent offline but acknowledged',
|
|
||||||
acknowledged: true,
|
acknowledged: true,
|
||||||
timestamp: '2026-06-22T10:00:00Z',
|
timestamp: '2026-06-22T11:00:00Z',
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alarm-4',
|
|
||||||
project_id: 'project-1',
|
|
||||||
asset_id: 'asset-2',
|
|
||||||
variable_alias: 'pressure_psi',
|
|
||||||
alarm_type: 'ABOVE_MAX',
|
|
||||||
message: 'Other well pressure high',
|
|
||||||
acknowledged: false,
|
|
||||||
timestamp: '2026-06-22T09:00:00Z',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const cards = buildWellOverviewCards(
|
const cards = buildWellOverviewCards(
|
||||||
wells,
|
wells,
|
||||||
[{ asset_id: 'asset-1', asset: 'Pozo Norte', last_updated: '2026-06-22T12:00:00Z', telemetry: { pressure_psi: 520, temp_c: 80 } }],
|
[{ asset_id: 'asset-1', asset: 'Pozo Norte', last_updated: '2026-06-22T12:00:00Z', telemetry: { pressure_psi: 520, temp_c: 80 } }],
|
||||||
[
|
|
||||||
{ id: 'var-1', device_id: 'device-1', assetId: 'asset-1', address: '40001', data_type: 'float32' as const, alias: 'configured_pressure', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-2', device_id: 'device-1', assetId: 'asset-1', address: '40002', data_type: 'float32' as const, alias: 'disabled_temp', unit: '°C', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: false, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-3', device_id: 'device-2', assetId: 'asset-2', address: '40003', data_type: 'float32' as const, alias: 'configured_flow', unit: 'BPD', polling_interval_ms: 5000, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
],
|
|
||||||
alarms,
|
alarms,
|
||||||
|
[
|
||||||
|
{ time: '2026-06-22T11:58:00Z', data: { pressure_psi: 510 } },
|
||||||
|
{ time: '2026-06-22T11:59:00Z', data: { pressure_psi: 520 } },
|
||||||
|
],
|
||||||
|
'asset-1',
|
||||||
'Campo Norte'
|
'Campo Norte'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1106,17 +839,17 @@ describe('dashboard view-model helpers', () => {
|
|||||||
name: 'Pozo Norte',
|
name: 'Pozo Norte',
|
||||||
context: 'PZ-01 · Campo Norte',
|
context: 'PZ-01 · Campo Norte',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
variablesCount: 1,
|
variablesCount: 2,
|
||||||
criticalCount: 1,
|
criticalCount: 1,
|
||||||
hasTelemetry: true,
|
hasTelemetry: true,
|
||||||
lastUpdated: '2026-06-22T12:00:00Z',
|
sparklineValues: [510, 520],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'asset-2',
|
id: 'asset-2',
|
||||||
variablesCount: 1,
|
variablesCount: 0,
|
||||||
criticalCount: 1,
|
criticalCount: 0,
|
||||||
hasTelemetry: false,
|
hasTelemetry: false,
|
||||||
lastUpdated: undefined,
|
sparklineValues: [],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1135,18 +868,8 @@ describe('dashboard view-model helpers', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'alarm-2',
|
id: 'alarm-2',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
acknowledged: false,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'alarm-3',
|
|
||||||
severity: 'critical',
|
|
||||||
acknowledged: true,
|
acknowledged: true,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
id: 'alarm-4',
|
|
||||||
severity: 'critical',
|
|
||||||
acknowledged: false,
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { DashboardEmptyState } from '../components/DashboardEmptyState'
|
|||||||
import { DashboardPanoramaOverview } from '../components/DashboardPanoramaOverview'
|
import { DashboardPanoramaOverview } from '../components/DashboardPanoramaOverview'
|
||||||
import { DashboardPanoramaLowerSection } from '../components/DashboardPanoramaLowerSection'
|
import { DashboardPanoramaLowerSection } from '../components/DashboardPanoramaLowerSection'
|
||||||
import { TelemetryChart } from '../components/TelemetryChart'
|
import { TelemetryChart } from '../components/TelemetryChart'
|
||||||
import { getAssetProjectId, getTelemetryFrequencyClass, normalizeAssetDataOrigin, type AssetData } from '../utils/telemetry-normalization'
|
import { getAssetProjectId, type AssetData } from '../utils/telemetry-normalization'
|
||||||
import {
|
import {
|
||||||
buildAssetOptions,
|
buildAssetOptions,
|
||||||
buildDynamicStats,
|
buildDynamicStats,
|
||||||
@@ -112,18 +112,15 @@ export default function DashboardPage() {
|
|||||||
asset_id: assetId,
|
asset_id: assetId,
|
||||||
asset: assetName,
|
asset: assetName,
|
||||||
project_id: payloadProjectId ?? existing?.project_id ?? resolvedProjectId ?? undefined,
|
project_id: payloadProjectId ?? existing?.project_id ?? resolvedProjectId ?? undefined,
|
||||||
source: payload.source,
|
|
||||||
frequency_class: payload.frequency_class,
|
|
||||||
last_updated: new Date().toISOString(),
|
last_updated: new Date().toISOString(),
|
||||||
telemetry: payload,
|
telemetry: payload,
|
||||||
};
|
};
|
||||||
const normalizedNewData = normalizeAssetDataOrigin(newData)
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[index] = normalizeAssetDataOrigin({ ...updated[index], ...normalizedNewData });
|
updated[index] = { ...updated[index], ...newData };
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
return [...prev, normalizedNewData];
|
return [...prev, newData];
|
||||||
});
|
});
|
||||||
setHistory(prev => {
|
setHistory(prev => {
|
||||||
const newHistoryRecord = { time: new Date().toISOString(), data: payload };
|
const newHistoryRecord = { time: new Date().toISOString(), data: payload };
|
||||||
@@ -147,7 +144,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardSummary()
|
fetchDashboardSummary()
|
||||||
.then((res) => { setAssets((res.data || []).map(normalizeAssetDataOrigin)); setLoading(false); })
|
.then((res) => { setAssets(res.data || []); setLoading(false); })
|
||||||
.catch((err) => console.error('Dashboard Error:', err))
|
.catch((err) => console.error('Dashboard Error:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -246,10 +243,6 @@ export default function DashboardPage() {
|
|||||||
if (selectedAsset === 'all') return null
|
if (selectedAsset === 'all') return null
|
||||||
return scopedAssets[0] || null
|
return scopedAssets[0] || null
|
||||||
}, [scopedAssets, selectedAsset])
|
}, [scopedAssets, selectedAsset])
|
||||||
const activeFrequencyClass = useMemo(() => (
|
|
||||||
activeAssetData ? getTelemetryFrequencyClass(activeAssetData) : null
|
|
||||||
), [activeAssetData])
|
|
||||||
const statusFrequencyClass = selectedAsset === 'all' ? 'HF' : activeFrequencyClass
|
|
||||||
|
|
||||||
const dynamicStats = useMemo(() => {
|
const dynamicStats = useMemo(() => {
|
||||||
return buildDynamicStats(activeAssetData)
|
return buildDynamicStats(activeAssetData)
|
||||||
@@ -264,20 +257,20 @@ export default function DashboardPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const complianceStatus = useMemo(() => {
|
const complianceStatus = useMemo(() => {
|
||||||
return getComplianceStatus(history, selectedAsset, new Date(), 0, statusFrequencyClass)
|
return getComplianceStatus(history, selectedAsset)
|
||||||
}, [history, selectedAsset, statusFrequencyClass])
|
}, [history, selectedAsset])
|
||||||
|
|
||||||
const status = useMemo(() => {
|
const status = useMemo(() => {
|
||||||
return getDashboardStatus(isConnected, complianceStatus.isStable, statusFrequencyClass)
|
return getDashboardStatus(isConnected, complianceStatus.isStable)
|
||||||
}, [isConnected, complianceStatus.isStable, statusFrequencyClass])
|
}, [isConnected, complianceStatus.isStable])
|
||||||
|
|
||||||
const assetOptions = useMemo(() => buildAssetOptions(wellAssets), [wellAssets])
|
const assetOptions = useMemo(() => buildAssetOptions(wellAssets), [wellAssets])
|
||||||
const connectedWellCount = useMemo(() => (
|
const connectedWellCount = useMemo(() => (
|
||||||
countConnectedWells(scopedAssets, scopedWellAssets)
|
countConnectedWells(scopedAssets, scopedWellAssets)
|
||||||
), [scopedAssets, scopedWellAssets])
|
), [scopedAssets, scopedWellAssets])
|
||||||
const wellOverviewCards = useMemo(() => (
|
const wellOverviewCards = useMemo(() => (
|
||||||
buildWellOverviewCards(scopedWellAssets, scopedAssets, scopedVariableCatalog, scopedAlarms, resolvedProjectName)
|
buildWellOverviewCards(scopedWellAssets, scopedAssets, scopedAlarms, history, selectedAsset, resolvedProjectName)
|
||||||
), [scopedWellAssets, scopedAssets, scopedVariableCatalog, scopedAlarms, resolvedProjectName])
|
), [scopedWellAssets, scopedAssets, scopedAlarms, history, selectedAsset, resolvedProjectName])
|
||||||
const monitoredVariableCount = useMemo(() => (
|
const monitoredVariableCount = useMemo(() => (
|
||||||
wellOverviewCards.reduce((total, well) => total + well.variablesCount, 0)
|
wellOverviewCards.reduce((total, well) => total + well.variablesCount, 0)
|
||||||
), [wellOverviewCards])
|
), [wellOverviewCards])
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { act, Children, isValidElement } from 'react'
|
import { act } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
import { getProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
||||||
import { fetchAssetTelemetryHistory, fetchDashboardSummary } from '../api/dashboard-api'
|
import { fetchAssetTelemetryHistory } from '../api/dashboard-api'
|
||||||
import { TrendsView } from './TrendsView'
|
import { TrendsView } from './TrendsView'
|
||||||
|
|
||||||
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
||||||
@@ -13,27 +13,17 @@ vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
|||||||
|
|
||||||
vi.mock('../api/dashboard-api', () => ({
|
vi.mock('../api/dashboard-api', () => ({
|
||||||
fetchAssetTelemetryHistory: vi.fn(),
|
fetchAssetTelemetryHistory: vi.fn(),
|
||||||
fetchDashboardSummary: vi.fn(),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('recharts', () => {
|
vi.mock('recharts', () => {
|
||||||
function ChartShell({ children }: { children?: ReactNode }) {
|
function ChartShell({ children }: { children?: ReactNode }) {
|
||||||
const visibleChildren = Children.map(children, (child) => {
|
return <div>{children ? null : null}</div>
|
||||||
if (isValidElement(child) && typeof child.type === 'string' && ['defs', 'linearGradient', 'stop'].includes(child.type)) return null
|
|
||||||
return child
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div>{visibleChildren}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartLine({ name }: { name?: string }) {
|
function ChartLine({ name }: { name?: string }) {
|
||||||
return <div>{name}</div>
|
return <div>{name}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReferenceLine({ label, y }: { label?: { value?: string }; y?: number }) {
|
|
||||||
return <div data-reference-line={y}>{label?.value}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Area: () => null,
|
Area: () => null,
|
||||||
AreaChart: ChartShell,
|
AreaChart: ChartShell,
|
||||||
@@ -41,7 +31,6 @@ vi.mock('recharts', () => {
|
|||||||
Legend: () => <div>Legend</div>,
|
Legend: () => <div>Legend</div>,
|
||||||
Line: ChartLine,
|
Line: ChartLine,
|
||||||
LineChart: ChartShell,
|
LineChart: ChartShell,
|
||||||
ReferenceLine,
|
|
||||||
ResponsiveContainer: ChartShell,
|
ResponsiveContainer: ChartShell,
|
||||||
Tooltip: () => null,
|
Tooltip: () => null,
|
||||||
XAxis: () => null,
|
XAxis: () => null,
|
||||||
@@ -55,7 +44,6 @@ describe('TrendsView', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({ data: [] })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -158,122 +146,6 @@ describe('TrendsView', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockConfigWithManyVariables(count: number) {
|
|
||||||
const variables = Array.from({ length: count }, (_, index) => {
|
|
||||||
const number = index + 1
|
|
||||||
const padded = number.toString().padStart(2, '0')
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `var-${number}`,
|
|
||||||
device_id: 'device-1',
|
|
||||||
address: `${40000 + number}`,
|
|
||||||
data_type: 'float32',
|
|
||||||
alias: `VAR_${padded}`,
|
|
||||||
unit: 'psi',
|
|
||||||
polling_interval_ms: 5000,
|
|
||||||
alarm_enabled: false,
|
|
||||||
byte_order: 'ABCD',
|
|
||||||
metadata: { label: `Variable ${padded}`, category: 'Presión' },
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
|
||||||
project: {
|
|
||||||
id: 'project-1',
|
|
||||||
name: 'Proyecto Uno',
|
|
||||||
client: 'Cliente',
|
|
||||||
operator_name: 'Operador',
|
|
||||||
contract_number: 'C-1',
|
|
||||||
is_active: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
assets: [{
|
|
||||||
asset: {
|
|
||||||
id: 'well-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
code: 'PZ-01',
|
|
||||||
name: 'Pozo Norte',
|
|
||||||
asset_type: 'POZO',
|
|
||||||
is_active: true,
|
|
||||||
metadata: {},
|
|
||||||
is_collector_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
devices: [{
|
|
||||||
device: {
|
|
||||||
id: 'device-1',
|
|
||||||
asset_id: 'well-1',
|
|
||||||
name: 'PLC Norte',
|
|
||||||
protocol: 'MODBUS_TCP',
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 502,
|
|
||||||
protocol_config: {},
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
variables,
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleTrendCardTitles() {
|
|
||||||
return Array.from(container.querySelectorAll('article h3')).map((heading) => heading.textContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleSummaryTitles() {
|
|
||||||
return Array.from(container.querySelectorAll('article h4')).map((heading) => heading.textContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buttonByName(name: string | RegExp) {
|
|
||||||
const matcher = typeof name === 'string' ? (value: string) => value === name : (value: string) => name.test(value)
|
|
||||||
return Array.from(container.querySelectorAll('button')).find((button) => matcher(button.textContent ?? '') || matcher(button.getAttribute('aria-label') ?? '')) as HTMLButtonElement
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailDialog() {
|
|
||||||
return container.querySelector('[role="dialog"]') as HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectConfigWithDuplicateAlias() {
|
|
||||||
return {
|
|
||||||
project: {
|
|
||||||
id: 'project-1',
|
|
||||||
name: 'Proyecto Uno',
|
|
||||||
client: 'Cliente',
|
|
||||||
operator_name: 'Operador',
|
|
||||||
contract_number: 'C-1',
|
|
||||||
is_active: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
assets: [{
|
|
||||||
asset: {
|
|
||||||
id: 'well-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
code: 'PZ-01',
|
|
||||||
name: 'Pozo Norte',
|
|
||||||
asset_type: 'POZO',
|
|
||||||
is_active: true,
|
|
||||||
metadata: {},
|
|
||||||
is_collector_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
devices: [{
|
|
||||||
device: { id: 'device-1', asset_id: 'well-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z' },
|
|
||||||
variables: [
|
|
||||||
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'PRESION_CABEZA', unit: 'psi', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Presión cabeza A', category: 'Presión' }, is_enabled: true, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z' },
|
|
||||||
{ id: 'var-2', device_id: 'device-1', address: '40003', data_type: 'float32', alias: 'PRESION_CABEZA', unit: 'psi', polling_interval_ms: 60000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Presión cabeza B', category: 'Presión' }, is_enabled: true, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z' },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('shows an honest empty state when there is no active project', async () => {
|
it('shows an honest empty state when there is no active project', async () => {
|
||||||
await renderView()
|
await renderView()
|
||||||
|
|
||||||
@@ -302,72 +174,6 @@ describe('TrendsView', () => {
|
|||||||
expect(container.textContent).toContain('Sin pozos con variables')
|
expect(container.textContent).toContain('Sin pozos con variables')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not expose unidentified POZO assets as selectable wells', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
|
||||||
project: {
|
|
||||||
id: 'project-1',
|
|
||||||
name: 'Proyecto Uno',
|
|
||||||
client: 'Cliente',
|
|
||||||
operator_name: 'Operador',
|
|
||||||
contract_number: 'C-1',
|
|
||||||
is_active: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
assets: [{
|
|
||||||
asset: {
|
|
||||||
id: 'well-unidentified',
|
|
||||||
project_id: 'project-1',
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
asset_type: 'POZO',
|
|
||||||
is_active: true,
|
|
||||||
metadata: {},
|
|
||||||
is_collector_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
devices: [{
|
|
||||||
device: {
|
|
||||||
id: 'device-1',
|
|
||||||
asset_id: 'well-unidentified',
|
|
||||||
name: 'PLC sin identificar',
|
|
||||||
protocol: 'MODBUS_TCP',
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 502,
|
|
||||||
protocol_config: {},
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
variables: [{
|
|
||||||
id: 'var-1',
|
|
||||||
device_id: 'device-1',
|
|
||||||
address: '40001',
|
|
||||||
data_type: 'float32',
|
|
||||||
alias: 'PRESION_CABEZA',
|
|
||||||
unit: 'psi',
|
|
||||||
polling_interval_ms: 5000,
|
|
||||||
alarm_enabled: false,
|
|
||||||
byte_order: 'ABCD',
|
|
||||||
metadata: { label: 'Presión de cabeza', category: 'Presión' },
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-01T00:00:00Z',
|
|
||||||
updated_at: '2026-06-01T00:00:00Z',
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('1 pozo con identificación incompleta')
|
|
||||||
expect(container.textContent).toContain('Sin pozos válidos para tendencias')
|
|
||||||
expect(container.textContent).not.toContain('Pozo sin nombre')
|
|
||||||
expect(fetchAssetTelemetryHistory).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults to multiple variables and renders real series controls', async () => {
|
it('defaults to multiple variables and renders real series controls', async () => {
|
||||||
mockConfig()
|
mockConfig()
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
||||||
@@ -386,463 +192,10 @@ describe('TrendsView', () => {
|
|||||||
expect(container.textContent).toContain('Comparativo de variables')
|
expect(container.textContent).toContain('Comparativo de variables')
|
||||||
expect(container.textContent).toContain('Presión de cabeza')
|
expect(container.textContent).toContain('Presión de cabeza')
|
||||||
expect(container.textContent).toContain('Temperatura motor')
|
expect(container.textContent).toContain('Temperatura motor')
|
||||||
expect(container.textContent).toContain('Resumen operativo')
|
|
||||||
expect(container.textContent).toContain('Estadísticas con histórico real')
|
|
||||||
expect(container.textContent).toContain('Rango consultado: 1h')
|
|
||||||
expect(container.textContent).toContain('Último102 psi')
|
|
||||||
expect(container.textContent).toContain('Mín101 psi')
|
|
||||||
expect(container.textContent).toContain('Máx102 psi')
|
|
||||||
expect(container.textContent).toContain('101.5 psi')
|
|
||||||
expect(container.textContent).toContain('Muestras2')
|
|
||||||
expect(container.textContent).toContain('Última muestra')
|
|
||||||
expect(container.textContent).toContain('PLC Norte')
|
|
||||||
expect(container.textContent).toContain('cada 5 s')
|
|
||||||
expect(container.textContent).toContain('55.5 °C')
|
|
||||||
expect(container.textContent).toContain('+1% vs muestra previa')
|
expect(container.textContent).toContain('+1% vs muestra previa')
|
||||||
expect(container.textContent).toContain('+1.8% vs muestra previa')
|
expect(container.textContent).toContain('+1.8% vs muestra previa')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens a clickable variable detail modal with selected variable and well metadata', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 56 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const openButton = container.querySelector('[aria-label="Abrir detalle histórico de Presión de cabeza"]') as HTMLButtonElement
|
|
||||||
expect(openButton).toBeTruthy()
|
|
||||||
|
|
||||||
await act(async () => openButton.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const dialog = detailDialog()
|
|
||||||
expect(dialog?.textContent).toContain('Detalle histórico')
|
|
||||||
expect(dialog?.textContent).toContain('Presión de cabeza')
|
|
||||||
expect(dialog?.textContent).toContain('PRESION_CABEZA')
|
|
||||||
expect(dialog?.textContent).toContain('Pozo Norte')
|
|
||||||
expect(dialog?.textContent).toContain('Alta frecuencia')
|
|
||||||
expect(dialog?.textContent).toContain('PLC Norte')
|
|
||||||
expect(dialog?.textContent).toContain('psi')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows modal history for the selected variable only', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 999 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 1000 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => buttonByName('Abrir detalle histórico de Presión de cabeza').click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const dialogText = detailDialog()?.textContent ?? ''
|
|
||||||
expect(dialogText).toContain('Último102 psi')
|
|
||||||
expect(dialogText).toContain('Mín101 psi')
|
|
||||||
expect(dialogText).toContain('Máx102 psi')
|
|
||||||
expect(dialogText).not.toContain('999 °C')
|
|
||||||
expect(dialogText).not.toContain('1000 °C')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fetches the selected modal range with the current well and keeps the modal open', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 56 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => buttonByName('Abrir detalle histórico de Presión de cabeza').click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const range7d = Array.from(detailDialog()?.querySelectorAll('button') ?? []).find((button) => button.textContent === '7d') as HTMLButtonElement
|
|
||||||
await act(async () => range7d.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenLastCalledWith('well-1', 7, 2016)
|
|
||||||
expect(detailDialog()?.textContent).toContain('Presión de cabeza')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates modal stats from the loaded range data', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 56 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T13:00:00Z', data: { values: { PRESION_CABEZA: 10, TEMP_MOTOR: 900 } } },
|
|
||||||
{ time: '2026-06-24T13:05:00Z', data: { values: { PRESION_CABEZA: 20, TEMP_MOTOR: 950 } } },
|
|
||||||
{ time: '2026-06-24T13:10:00Z', data: { values: { PRESION_CABEZA: 30, TEMP_MOTOR: 1000 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => buttonByName('Abrir detalle histórico de Presión de cabeza').click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const dialogText = detailDialog()?.textContent ?? ''
|
|
||||||
expect(dialogText).toContain('Último30 psi')
|
|
||||||
expect(dialogText).toContain('Mín10 psi')
|
|
||||||
expect(dialogText).toContain('Máx30 psi')
|
|
||||||
expect(dialogText).toContain('Promedio20 psi')
|
|
||||||
expect(dialogText).toContain('Muestras3')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows an empty modal range state when the selected variable has no detailed history', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
records: [{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } }],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ records: [] })
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => buttonByName('Abrir detalle histórico de Presión de cabeza').click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(detailDialog()?.textContent).toContain('Sin histórico disponible para este rango.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closes the modal with the close button and Escape', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } }],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => buttonByName('Abrir detalle histórico de Presión de cabeza').click())
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => buttonByName('Cerrar detalle histórico').click())
|
|
||||||
await flushEffects()
|
|
||||||
expect(detailDialog()).toBeNull()
|
|
||||||
|
|
||||||
await act(async () => buttonByName('Abrir detalle histórico de Presión de cabeza').click())
|
|
||||||
await flushEffects()
|
|
||||||
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })))
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(detailDialog()).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('paginates selected variables after excluding selections without real history', async () => {
|
|
||||||
mockConfigWithManyVariables(14)
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{
|
|
||||||
time: '2026-06-24T12:00:00Z',
|
|
||||||
data: { values: Object.fromEntries(Array.from({ length: 13 }, (_, index) => [`VAR_${(index + 1).toString().padStart(2, '0')}`, 100 + index])) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
time: '2026-06-24T12:05:00Z',
|
|
||||||
data: { values: Object.fromEntries(Array.from({ length: 13 }, (_, index) => [`VAR_${(index + 1).toString().padStart(2, '0')}`, 110 + index])) },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Página 1 de 2')
|
|
||||||
expect(container.textContent).toContain('12 visibles de 13 variables con muestras reales')
|
|
||||||
expect(visibleTrendCardTitles()).toEqual(Array.from({ length: 12 }, (_, index) => `Variable ${(index + 1).toString().padStart(2, '0')}`))
|
|
||||||
expect(visibleSummaryTitles()).toEqual(Array.from({ length: 12 }, (_, index) => `Variable ${(index + 1).toString().padStart(2, '0')}`))
|
|
||||||
expect(visibleTrendCardTitles()).not.toContain('Variable 14')
|
|
||||||
expect(visibleSummaryTitles()).not.toContain('Variable 14')
|
|
||||||
|
|
||||||
const previousButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Anterior') as HTMLButtonElement
|
|
||||||
const nextButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Siguiente') as HTMLButtonElement
|
|
||||||
expect(previousButton.disabled).toBe(true)
|
|
||||||
expect(nextButton.disabled).toBe(false)
|
|
||||||
|
|
||||||
await act(async () => nextButton.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Página 2 de 2')
|
|
||||||
expect(visibleTrendCardTitles()).toEqual(['Variable 13'])
|
|
||||||
expect(visibleSummaryTitles()).toEqual(['Variable 13'])
|
|
||||||
expect(visibleTrendCardTitles()).not.toContain('Variable 14')
|
|
||||||
expect(visibleSummaryTitles()).not.toContain('Variable 14')
|
|
||||||
expect(previousButton.disabled).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resets variable pagination to the first page when variable selection changes', async () => {
|
|
||||||
mockConfigWithManyVariables(14)
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{
|
|
||||||
time: '2026-06-24T12:00:00Z',
|
|
||||||
data: { values: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`VAR_${(index + 1).toString().padStart(2, '0')}`, 100 + index])) },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
time: '2026-06-24T12:05:00Z',
|
|
||||||
data: { values: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`VAR_${(index + 1).toString().padStart(2, '0')}`, 110 + index])) },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const nextButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Siguiente') as HTMLButtonElement
|
|
||||||
await act(async () => nextButton.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Página 2 de 2')
|
|
||||||
expect(visibleTrendCardTitles()).toEqual(['Variable 13', 'Variable 14'])
|
|
||||||
|
|
||||||
const variable14Button = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Variable 14')) as HTMLButtonElement
|
|
||||||
await act(async () => variable14Button.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Página 1 de 2')
|
|
||||||
expect(visibleTrendCardTitles()).toEqual(Array.from({ length: 12 }, (_, index) => `Variable ${(index + 1).toString().padStart(2, '0')}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders min and max reference lines from real series values', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 108, TEMP_MOTOR: 58 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Mín 101 psi')
|
|
||||||
expect(container.textContent).toContain('Máx 108 psi')
|
|
||||||
expect(container.textContent).toContain('Mín 55 °C')
|
|
||||||
expect(container.textContent).toContain('Máx 58 °C')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render duplicate max reference lines when a series min equals max', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const referenceLineLabels = Array.from(container.querySelectorAll('[data-reference-line]')).map((line) => line.textContent)
|
|
||||||
expect(referenceLineLabels).toEqual(['Mín 101 psi', 'Mín 55 °C'])
|
|
||||||
expect(referenceLineLabels).not.toContain('Máx 101 psi')
|
|
||||||
expect(referenceLineLabels).not.toContain('Máx 55 °C')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adds LF capture fields from the dashboard summary as selectable trends', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'PWA',
|
|
||||||
frequency_class: 'LF',
|
|
||||||
last_updated: '2026-06-24T12:05:00Z',
|
|
||||||
telemetry: { values: { PRESION_CABEZA: 102, caudal_bopd: 75 }, source: 'PWA', frequency_class: 'LF' },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, caudal_bopd: 72 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, caudal_bopd: 75 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(fetchDashboardSummary).toHaveBeenCalled()
|
|
||||||
expect(container.textContent).toContain('Caudal bopd')
|
|
||||||
expect(container.textContent).toContain('75 BPD')
|
|
||||||
expect(container.textContent).toContain('baja frecuencia')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters trend variables by real frequency class and labels chips with history state', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'PWA',
|
|
||||||
frequency_class: 'LF',
|
|
||||||
last_updated: '2026-06-24T12:05:00Z',
|
|
||||||
telemetry: { values: { caudal_bopd: 75 }, source: 'PWA', frequency_class: 'LF' },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, caudal_bopd: 72 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, caudal_bopd: 75 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Presión de cabezaHFcon histórico')
|
|
||||||
expect(container.textContent).toContain('Caudal bopdLFcon histórico')
|
|
||||||
|
|
||||||
const lowFrequencyButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Baja frecuencia') as HTMLButtonElement
|
|
||||||
await act(async () => lowFrequencyButton.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Caudal bopdLFcon histórico')
|
|
||||||
expect(container.textContent).not.toContain('Presión de cabezaHFcon histórico')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('warns when selected well variables reuse the same alias', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
|
||||||
...projectConfigWithDuplicateAlias(),
|
|
||||||
})
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101 } } }],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Alias duplicado en este pozo: PRESION_CABEZA')
|
|
||||||
expect(container.textContent).toContain('Presión cabeza AHFcon histórico')
|
|
||||||
expect(container.textContent).toContain('Presión cabeza BLFcon histórico')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps duplicate alias warning visible when frequency filter hides one duplicate', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue(projectConfigWithDuplicateAlias())
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101 } } }],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const highFrequencyButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Alta frecuencia') as HTMLButtonElement
|
|
||||||
await act(async () => highFrequencyButton.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Presión cabeza AHFcon histórico')
|
|
||||||
expect(container.textContent).not.toContain('Presión cabeza BLFcon histórico')
|
|
||||||
expect(container.textContent).toContain('Alias duplicado en este pozo: PRESION_CABEZA')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not refetch history immediately when history-derived capture fields change trend options', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
|
||||||
records: [
|
|
||||||
{
|
|
||||||
time: '2026-06-24T12:00:00Z',
|
|
||||||
data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55, caudal_bopd: 72 }, source: 'PWA', frequency_class: 'LF' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Caudal bopd')
|
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
await act(async () => { vi.advanceTimersByTime(4_999) })
|
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
await act(async () => { vi.advanceTimersByTime(1) })
|
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the last valid history visible when a refresh fails', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 56 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.mockRejectedValueOnce(new Error('network down'))
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Comparativo de variables')
|
|
||||||
expect(container.textContent).toContain('102 psi')
|
|
||||||
|
|
||||||
await act(async () => { vi.advanceTimersByTime(5000) })
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenCalledTimes(2)
|
|
||||||
expect(container.textContent).toContain('No se pudo actualizar el historial')
|
|
||||||
expect(container.textContent).toContain('Comparativo de variables')
|
|
||||||
expect(container.textContent).toContain('102 psi')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not show stale history when a different range request fails', async () => {
|
|
||||||
mockConfig()
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
records: [
|
|
||||||
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
|
|
||||||
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 56 } } },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.mockRejectedValueOnce(new Error('range unavailable'))
|
|
||||||
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Comparativo de variables')
|
|
||||||
expect(container.textContent).toContain('102 psi')
|
|
||||||
|
|
||||||
const range24h = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === '24h') as HTMLButtonElement
|
|
||||||
await act(async () => range24h.click())
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenLastCalledWith('well-1', 1, 288)
|
|
||||||
expect(container.textContent).toContain('No se pudo cargar la vista')
|
|
||||||
expect(container.textContent).toContain('No se pudo cargar el historial real del pozo seleccionado')
|
|
||||||
expect(container.textContent).not.toContain('Comparativo de variables')
|
|
||||||
expect(container.textContent).not.toContain('102 psi')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows an honest one-point hint without fabricating a curve', async () => {
|
it('shows an honest one-point hint without fabricating a curve', async () => {
|
||||||
mockConfig()
|
mockConfig()
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { AlertTriangle, Activity, TrendingUp, X } from 'lucide-react'
|
import { AlertTriangle, Activity, TrendingUp } from 'lucide-react'
|
||||||
import { Area, AreaChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryResponse } from '../api/dashboard-api'
|
import { fetchAssetTelemetryHistory, type TelemetryHistoryResponse } from '../api/dashboard-api'
|
||||||
import { buildTrendMultiSeries, buildTrendSeries, buildTrendWellOptions, getUnidentifiedTrendWells, type TrendMultiSeries, type TrendVariableOption } from '../utils/trends-view-model'
|
import { buildTrendMultiSeries, buildTrendWellOptions, type TrendMultiSeries } from '../utils/trends-view-model'
|
||||||
import { getProjectFullConfig, type EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
import { getProjectFullConfig, type EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import type { AssetData } from '../utils/telemetry-normalization'
|
|
||||||
|
|
||||||
const rangeOptions = [
|
const rangeOptions = [
|
||||||
{ label: '1h', days: 1, limit: 12 },
|
{ label: '1h', days: 1, limit: 12 },
|
||||||
@@ -15,48 +14,18 @@ const rangeOptions = [
|
|||||||
{ label: '30d', days: 30, limit: 2016 },
|
{ label: '30d', days: 30, limit: 2016 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const VARIABLES_PER_PAGE = 12
|
|
||||||
|
|
||||||
type FrequencyFilter = 'all' | 'HF' | 'LF'
|
|
||||||
|
|
||||||
const frequencyFilterOptions: Array<{ value: FrequencyFilter; label: string }> = [
|
|
||||||
{ value: 'all', label: 'Todas' },
|
|
||||||
{ value: 'HF', label: 'Alta frecuencia' },
|
|
||||||
{ value: 'LF', label: 'Baja frecuencia' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type HistoryCache = {
|
|
||||||
key: string
|
|
||||||
history: TelemetryHistoryResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
type DetailHistoryCache = HistoryCache & {
|
|
||||||
variableId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrendsView() {
|
export function TrendsView() {
|
||||||
const activeProjectId = useAuthStore((state) => state.activeProjectId)
|
const activeProjectId = useAuthStore((state) => state.activeProjectId)
|
||||||
const projects = useAuthStore((state) => state.projects)
|
const projects = useAuthStore((state) => state.projects)
|
||||||
const [config, setConfig] = useState<EdgeProjectFullConfig | null>(null)
|
const [config, setConfig] = useState<EdgeProjectFullConfig | null>(null)
|
||||||
const [summaryTelemetry, setSummaryTelemetry] = useState<AssetData[]>([])
|
|
||||||
const [history, setHistory] = useState<TelemetryHistoryResponse | null>(null)
|
const [history, setHistory] = useState<TelemetryHistoryResponse | null>(null)
|
||||||
const [selectedAssetId, setSelectedAssetId] = useState('')
|
const [selectedAssetId, setSelectedAssetId] = useState('')
|
||||||
const [selectedVariableIds, setSelectedVariableIds] = useState<string[]>([])
|
const [selectedAliases, setSelectedAliases] = useState<string[]>([])
|
||||||
const [frequencyFilter, setFrequencyFilter] = useState<FrequencyFilter>('all')
|
|
||||||
const [rangeIndex, setRangeIndex] = useState(0)
|
const [rangeIndex, setRangeIndex] = useState(0)
|
||||||
const [trendPage, setTrendPage] = useState(1)
|
|
||||||
const [isConfigLoading, setIsConfigLoading] = useState(true)
|
const [isConfigLoading, setIsConfigLoading] = useState(true)
|
||||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
||||||
const [selectedDetailVariable, setSelectedDetailVariable] = useState<TrendMultiSeries | null>(null)
|
|
||||||
const [detailRangeIndex, setDetailRangeIndex] = useState(0)
|
|
||||||
const [detailHistory, setDetailHistory] = useState<TelemetryHistoryResponse | null>(null)
|
|
||||||
const [isDetailHistoryLoading, setIsDetailHistoryLoading] = useState(false)
|
|
||||||
const [detailHistoryError, setDetailHistoryError] = useState<string | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [historyWarning, setHistoryWarning] = useState<string | null>(null)
|
|
||||||
const defaultedAssetIdRef = useRef('')
|
const defaultedAssetIdRef = useRef('')
|
||||||
const historyRef = useRef<HistoryCache | null>(null)
|
|
||||||
const detailHistoryRef = useRef<DetailHistoryCache | null>(null)
|
|
||||||
|
|
||||||
const resolvedProjectId = useMemo(() => {
|
const resolvedProjectId = useMemo(() => {
|
||||||
if (activeProjectId) return activeProjectId
|
if (activeProjectId) return activeProjectId
|
||||||
@@ -70,9 +39,7 @@ export function TrendsView() {
|
|||||||
if (!resolvedProjectId) {
|
if (!resolvedProjectId) {
|
||||||
setConfig(null)
|
setConfig(null)
|
||||||
setSelectedAssetId('')
|
setSelectedAssetId('')
|
||||||
setSelectedVariableIds([])
|
setSelectedAliases([])
|
||||||
setHistory(null)
|
|
||||||
historyRef.current = null
|
|
||||||
setIsConfigLoading(false)
|
setIsConfigLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,22 +48,15 @@ export function TrendsView() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [fullConfig, summary] = await Promise.all([
|
const fullConfig = await getProjectFullConfig(resolvedProjectId)
|
||||||
getProjectFullConfig(resolvedProjectId),
|
|
||||||
fetchDashboardSummary(),
|
|
||||||
])
|
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
setConfig(fullConfig)
|
setConfig(fullConfig)
|
||||||
setSummaryTelemetry(summary.data || [])
|
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
console.error('Trends config error:', loadError)
|
console.error('Trends config error:', loadError)
|
||||||
setConfig(null)
|
setConfig(null)
|
||||||
setSummaryTelemetry([])
|
|
||||||
setSelectedAssetId('')
|
setSelectedAssetId('')
|
||||||
setSelectedVariableIds([])
|
setSelectedAliases([])
|
||||||
setHistory(null)
|
|
||||||
historyRef.current = null
|
|
||||||
setError('No se pudo cargar la configuración real de pozos y variables.')
|
setError('No se pudo cargar la configuración real de pozos y variables.')
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setIsConfigLoading(false)
|
if (mounted) setIsConfigLoading(false)
|
||||||
@@ -108,96 +68,55 @@ export function TrendsView() {
|
|||||||
return () => { mounted = false }
|
return () => { mounted = false }
|
||||||
}, [resolvedProjectId])
|
}, [resolvedProjectId])
|
||||||
|
|
||||||
const historyTelemetry = useMemo<AssetData[]>(() => {
|
const wells = useMemo(() => buildTrendWellOptions(config), [config])
|
||||||
const records = history?.records ?? []
|
|
||||||
const latestRecord = records.at(-1)
|
|
||||||
if (!latestRecord || !selectedAssetId) return []
|
|
||||||
|
|
||||||
const data = latestRecord.data || latestRecord.values || latestRecord.telemetry || latestRecord
|
|
||||||
const time = latestRecord.time || latestRecord.created_at || latestRecord.timestamp || new Date().toISOString()
|
|
||||||
const assetName = config?.assets.find(({ asset }) => asset.id === selectedAssetId)?.asset.name ?? selectedAssetId
|
|
||||||
|
|
||||||
return [{
|
|
||||||
asset_id: selectedAssetId,
|
|
||||||
asset: assetName,
|
|
||||||
source: latestRecord.source,
|
|
||||||
frequency_class: latestRecord.frequency_class,
|
|
||||||
last_updated: time,
|
|
||||||
telemetry: data,
|
|
||||||
}]
|
|
||||||
}, [config?.assets, history?.records, selectedAssetId])
|
|
||||||
const wells = useMemo(() => buildTrendWellOptions(config, [...summaryTelemetry, ...historyTelemetry]), [config, historyTelemetry, summaryTelemetry])
|
|
||||||
const unidentifiedWells = useMemo(() => getUnidentifiedTrendWells(config), [config])
|
|
||||||
const selectedWell = useMemo(() => wells.find((well) => well.id === selectedAssetId) ?? wells[0] ?? null, [selectedAssetId, wells])
|
const selectedWell = useMemo(() => wells.find((well) => well.id === selectedAssetId) ?? wells[0] ?? null, [selectedAssetId, wells])
|
||||||
const filteredVariables = useMemo(() => {
|
|
||||||
const variables = selectedWell?.variables ?? []
|
|
||||||
if (frequencyFilter === 'all') return variables
|
|
||||||
return variables.filter((variable) => variable.frequencyClass === frequencyFilter)
|
|
||||||
}, [frequencyFilter, selectedWell?.variables])
|
|
||||||
const selectedVariables = useMemo(() => {
|
const selectedVariables = useMemo(() => {
|
||||||
return filteredVariables.filter((variable) => selectedVariableIds.includes(variable.id))
|
if (!selectedWell) return []
|
||||||
}, [filteredVariables, selectedVariableIds])
|
return selectedWell.variables.filter((variable) => selectedAliases.includes(variable.alias))
|
||||||
|
}, [selectedAliases, selectedWell])
|
||||||
const selectedRange = rangeOptions[rangeIndex] ?? rangeOptions[0]
|
const selectedRange = rangeOptions[rangeIndex] ?? rangeOptions[0]
|
||||||
const selectedWellId = selectedWell?.id ?? ''
|
|
||||||
const historyQueryKey = selectedWellId ? `${selectedWellId}:${selectedRange.days}:${selectedRange.limit}` : ''
|
|
||||||
const hasSelectedWellVariables = (selectedWell?.variables.length ?? 0) > 0
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedWell) {
|
if (!selectedWell) {
|
||||||
if (selectedAssetId) setSelectedAssetId('')
|
if (selectedAssetId) setSelectedAssetId('')
|
||||||
if (selectedVariableIds.length > 0) setSelectedVariableIds([])
|
if (selectedAliases.length > 0) setSelectedAliases([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedAssetId !== selectedWell.id) setSelectedAssetId(selectedWell.id)
|
if (selectedAssetId !== selectedWell.id) setSelectedAssetId(selectedWell.id)
|
||||||
const variableIds = filteredVariables.map((variable) => variable.id)
|
const aliases = selectedWell.variables.map((variable) => variable.alias)
|
||||||
if (defaultedAssetIdRef.current !== selectedWell.id) {
|
if (defaultedAssetIdRef.current !== selectedWell.id) {
|
||||||
defaultedAssetIdRef.current = selectedWell.id
|
defaultedAssetIdRef.current = selectedWell.id
|
||||||
setSelectedVariableIds(variableIds)
|
setSelectedAliases(aliases)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const validSelectedIds = selectedVariableIds.filter((id) => variableIds.includes(id))
|
const validSelectedAliases = selectedAliases.filter((alias) => aliases.includes(alias))
|
||||||
if (validSelectedIds.length !== selectedVariableIds.length) {
|
if (validSelectedAliases.length !== selectedAliases.length) {
|
||||||
setSelectedVariableIds(validSelectedIds)
|
setSelectedAliases(validSelectedAliases)
|
||||||
}
|
}
|
||||||
}, [filteredVariables, selectedAssetId, selectedVariableIds, selectedWell])
|
}, [selectedAliases, selectedAssetId, selectedWell])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
let mounted = true
|
||||||
|
|
||||||
async function loadHistory(showLoading: boolean) {
|
async function loadHistory(showLoading: boolean) {
|
||||||
if (!selectedWellId || !hasSelectedWellVariables) {
|
if (!selectedWell || selectedWell.variables.length === 0) {
|
||||||
setHistory(null)
|
setHistory(null)
|
||||||
historyRef.current = null
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestKey = historyQueryKey
|
|
||||||
if (showLoading && historyRef.current?.key !== requestKey) {
|
|
||||||
setHistory(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) setIsHistoryLoading(true)
|
if (showLoading) setIsHistoryLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setHistoryWarning(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextHistory = await fetchAssetTelemetryHistory(selectedWellId, selectedRange.days, selectedRange.limit)
|
const nextHistory = await fetchAssetTelemetryHistory(selectedWell.id, selectedRange.days, selectedRange.limit)
|
||||||
if (mounted) {
|
if (mounted) setHistory(nextHistory)
|
||||||
historyRef.current = { key: requestKey, history: nextHistory }
|
|
||||||
setHistory(nextHistory)
|
|
||||||
}
|
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
console.error('Trends history error:', loadError)
|
console.error('Trends history error:', loadError)
|
||||||
if (historyRef.current?.key === requestKey) {
|
|
||||||
setHistory(historyRef.current.history)
|
|
||||||
setHistoryWarning('No se pudo actualizar el historial. Se conserva la última consulta válida.')
|
|
||||||
} else {
|
|
||||||
setHistory(null)
|
setHistory(null)
|
||||||
setError('No se pudo cargar el historial real del pozo seleccionado.')
|
setError('No se pudo cargar el historial real del pozo seleccionado.')
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setIsHistoryLoading(false)
|
if (mounted) setIsHistoryLoading(false)
|
||||||
}
|
}
|
||||||
@@ -210,120 +129,24 @@ export function TrendsView() {
|
|||||||
mounted = false
|
mounted = false
|
||||||
window.clearInterval(refreshTimer)
|
window.clearInterval(refreshTimer)
|
||||||
}
|
}
|
||||||
}, [hasSelectedWellVariables, historyQueryKey, selectedRange.days, selectedRange.limit, selectedWellId])
|
}, [selectedRange.days, selectedRange.limit, selectedWell])
|
||||||
|
|
||||||
const selectedVariableKey = selectedVariableIds.join('|')
|
const multiSeries = useMemo(() => buildTrendMultiSeries(history, selectedVariables), [history, selectedVariables])
|
||||||
const allVariablesSelected = filteredVariables.length > 0 && filteredVariables.every((variable) => selectedVariableIds.includes(variable.id))
|
|
||||||
const historyPointsByAlias = useMemo(() => {
|
|
||||||
const next = new Map<string, number>()
|
|
||||||
for (const variable of filteredVariables) {
|
|
||||||
next.set(variable.alias, buildTrendSeries(history, variable.alias).length)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}, [filteredVariables, history])
|
|
||||||
const selectedVariablesWithHistory = useMemo(() => {
|
|
||||||
return selectedVariables.filter((variable) => (historyPointsByAlias.get(variable.alias) ?? 0) > 0)
|
|
||||||
}, [historyPointsByAlias, selectedVariables])
|
|
||||||
const totalTrendPages = Math.max(1, Math.ceil(selectedVariablesWithHistory.length / VARIABLES_PER_PAGE))
|
|
||||||
const safeTrendPage = Math.min(trendPage, totalTrendPages)
|
|
||||||
const visibleTrendVariables = useMemo(() => {
|
|
||||||
const start = (safeTrendPage - 1) * VARIABLES_PER_PAGE
|
|
||||||
return selectedVariablesWithHistory.slice(start, start + VARIABLES_PER_PAGE)
|
|
||||||
}, [safeTrendPage, selectedVariablesWithHistory])
|
|
||||||
const multiSeries = useMemo(() => buildTrendMultiSeries(history, visibleTrendVariables), [history, visibleTrendVariables])
|
|
||||||
const variableCards = multiSeries.series
|
const variableCards = multiSeries.series
|
||||||
const detailVariableOption = useMemo(() => {
|
|
||||||
if (!selectedDetailVariable) return null
|
|
||||||
return selectedWell?.variables.find((variable) => variable.id === selectedDetailVariable.id) ?? null
|
|
||||||
}, [selectedDetailVariable, selectedWell?.variables])
|
|
||||||
const detailRange = rangeOptions[detailRangeIndex] ?? rangeOptions[0]
|
|
||||||
const detailSeries = useMemo(() => {
|
|
||||||
if (!selectedDetailVariable || !detailVariableOption) return selectedDetailVariable
|
|
||||||
const [series] = buildTrendMultiSeries(detailHistory, [detailVariableOption]).series
|
|
||||||
return series.points.length > 0 || detailHistory ? series : selectedDetailVariable
|
|
||||||
}, [detailHistory, detailVariableOption, selectedDetailVariable])
|
|
||||||
const seriesWithSamples = variableCards.filter((serie) => serie.points.length > 0)
|
const seriesWithSamples = variableCards.filter((serie) => serie.points.length > 0)
|
||||||
const totalSamples = variableCards.reduce((sum, serie) => sum + serie.points.length, 0)
|
const totalSamples = variableCards.reduce((sum, serie) => sum + serie.points.length, 0)
|
||||||
const hasOnePointSeries = variableCards.some((serie) => serie.points.length === 1)
|
const hasOnePointSeries = variableCards.some((serie) => serie.points.length === 1)
|
||||||
const duplicateAliases = useMemo(() => {
|
const allVariablesSelected = selectedWell ? selectedAliases.length === selectedWell.variables.length : false
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const variable of selectedWell?.variables ?? []) {
|
|
||||||
counts.set(variable.alias, (counts.get(variable.alias) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
return Array.from(counts.entries()).filter(([, count]) => count > 1).map(([alias]) => alias)
|
|
||||||
}, [selectedWell?.variables])
|
|
||||||
|
|
||||||
useEffect(() => {
|
function toggleAlias(alias: string) {
|
||||||
setTrendPage(1)
|
setSelectedAliases((current) => {
|
||||||
}, [frequencyFilter, historyQueryKey, selectedVariableKey, selectedWellId])
|
if (current.includes(alias)) return current.filter((selectedAlias) => selectedAlias !== alias)
|
||||||
|
return [...current, alias]
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDetailVariable || !detailVariableOption) return
|
|
||||||
|
|
||||||
let mounted = true
|
|
||||||
const requestKey = `${detailVariableOption.assetId}:${detailVariableOption.id}:${detailRange.days}:${detailRange.limit}`
|
|
||||||
setIsDetailHistoryLoading(true)
|
|
||||||
setDetailHistoryError(null)
|
|
||||||
|
|
||||||
async function loadDetailHistory() {
|
|
||||||
try {
|
|
||||||
const nextHistory = await fetchAssetTelemetryHistory(detailVariableOption!.assetId, detailRange.days, detailRange.limit)
|
|
||||||
if (!mounted) return
|
|
||||||
detailHistoryRef.current = { key: requestKey, variableId: detailVariableOption!.id, history: nextHistory }
|
|
||||||
setDetailHistory(nextHistory)
|
|
||||||
} catch (loadError) {
|
|
||||||
if (!mounted) return
|
|
||||||
console.error('Trends detail history error:', loadError)
|
|
||||||
const cached = detailHistoryRef.current
|
|
||||||
if (cached?.key === requestKey && cached.variableId === detailVariableOption!.id) {
|
|
||||||
setDetailHistory(cached.history)
|
|
||||||
}
|
|
||||||
setDetailHistoryError('No se pudo actualizar el histórico detallado de esta variable.')
|
|
||||||
} finally {
|
|
||||||
if (mounted) setIsDetailHistoryLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadDetailHistory()
|
|
||||||
|
|
||||||
return () => { mounted = false }
|
|
||||||
}, [detailRange.days, detailRange.limit, detailVariableOption, selectedDetailVariable])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDetailVariable) return
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') setSelectedDetailVariable(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [selectedDetailVariable])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trendPage > totalTrendPages) setTrendPage(totalTrendPages)
|
|
||||||
}, [totalTrendPages, trendPage])
|
|
||||||
|
|
||||||
function toggleVariable(variableId: string) {
|
|
||||||
setSelectedVariableIds((current) => {
|
|
||||||
if (current.includes(variableId)) return current.filter((selectedId) => selectedId !== variableId)
|
|
||||||
return [...current, variableId]
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAllVariables() {
|
function selectAllVariables() {
|
||||||
setSelectedVariableIds(filteredVariables.map((variable) => variable.id))
|
setSelectedAliases(selectedWell?.variables.map((variable) => variable.alias) ?? [])
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(series: TrendMultiSeries) {
|
|
||||||
setSelectedDetailVariable(series)
|
|
||||||
setDetailRangeIndex(rangeIndex)
|
|
||||||
setDetailHistory(history)
|
|
||||||
setDetailHistoryError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDetail() {
|
|
||||||
setSelectedDetailVariable(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,13 +166,13 @@ export function TrendsView() {
|
|||||||
<div className="grid gap-3 rounded-2xl border border-[#2a3038] bg-[#11151b]/90 p-3 shadow-lg shadow-black/20 sm:grid-cols-[minmax(11rem,1fr)_auto] lg:min-w-[31rem]">
|
<div className="grid gap-3 rounded-2xl border border-[#2a3038] bg-[#11151b]/90 p-3 shadow-lg shadow-black/20 sm:grid-cols-[minmax(11rem,1fr)_auto] lg:min-w-[31rem]">
|
||||||
<label className="flex flex-col gap-1 text-xs font-medium text-slate-400">
|
<label className="flex flex-col gap-1 text-xs font-medium text-slate-400">
|
||||||
Pozo
|
Pozo
|
||||||
<select value={selectedWell?.id ?? ''} onChange={(event) => { setSelectedAssetId(event.target.value); setSelectedVariableIds([]) }} className="rounded-xl border border-[#2a3038] bg-[#0b0f14] px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-300">
|
<select value={selectedWell?.id ?? ''} onChange={(event) => { setSelectedAssetId(event.target.value); setSelectedAliases([]) }} className="rounded-xl border border-[#2a3038] bg-[#0b0f14] px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-300">
|
||||||
{wells.map((well) => <option key={well.id} value={well.id}>{well.name}</option>)}
|
{wells.map((well) => <option key={well.id} value={well.id}>{well.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-1 text-xs font-medium text-slate-400">
|
<div className="flex flex-col gap-1 text-xs font-medium text-slate-400">
|
||||||
Rango
|
Rango
|
||||||
<div className="grid grid-cols-4 gap-1 rounded-xl border border-[#2a3038] bg-[#0b0f14] p-1" role="group" aria-label="Rango de tiempo histórico">
|
<div className="grid grid-cols-4 gap-1 rounded-xl border border-[#2a3038] bg-[#0b0f14] p-1">
|
||||||
{rangeOptions.map((option, index) => (
|
{rangeOptions.map((option, index) => (
|
||||||
<button
|
<button
|
||||||
key={option.label}
|
key={option.label}
|
||||||
@@ -368,69 +191,32 @@ export function TrendsView() {
|
|||||||
<span className="font-mono font-semibold text-white">{totalSamples}</span>
|
<span className="font-mono font-semibold text-white">{totalSamples}</span>
|
||||||
<span className="text-emerald-200">Actualiza cada 5 s</span>
|
<span className="text-emerald-200">Actualiza cada 5 s</span>
|
||||||
</div>
|
</div>
|
||||||
{historyWarning ? <p className="sm:col-span-2 rounded-lg border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">{historyWarning}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unidentifiedWells.length > 0 ? (
|
|
||||||
<section aria-label="Pozos con identificación incompleta" className="relative mb-5 rounded-[1.25rem] border border-amber-300/20 bg-amber-400/10 p-3 text-sm text-amber-100 shadow-xl shadow-black/20">
|
|
||||||
<p className="font-semibold">{unidentifiedWells.length} pozo{unidentifiedWells.length === 1 ? '' : 's'} con identificación incompleta</p>
|
|
||||||
<p className="mt-1 text-xs text-amber-100/75">
|
|
||||||
Se excluyen del selector principal porque no tienen nombre ni código. Revisá el origen de datos o la configuración del asset antes de analizar tendencias.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section aria-label="Variables de tendencias" className="relative mb-5 rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/85 p-3 shadow-xl shadow-black/20">
|
<section aria-label="Variables de tendencias" className="relative mb-5 rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/85 p-3 shadow-xl shadow-black/20">
|
||||||
<div className="mb-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-slate-400">Variables</p>
|
<p className="text-xs font-medium text-slate-400">Variables</p>
|
||||||
<p className="mt-1 text-[11px] text-slate-500">Filtrá por frecuencia sin mezclar análisis HF/LF.</p>
|
<button type="button" onClick={selectAllVariables} className="rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-[11px] font-semibold text-orange-100 transition hover:border-orange-300/40" disabled={allVariablesSelected}>
|
||||||
</div>
|
Todas las variables
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="inline-flex rounded-full border border-[#2a3038] bg-[#0b0f14] p-1" role="group" aria-label="Filtro de frecuencia de variables">
|
|
||||||
{frequencyFilterOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFrequencyFilter(option.value)}
|
|
||||||
className={`rounded-full px-3 py-1 text-[11px] font-semibold transition ${frequencyFilter === option.value ? 'bg-cyan-300 text-slate-950' : 'text-slate-400 hover:text-white'}`}
|
|
||||||
aria-pressed={frequencyFilter === option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={selectAllVariables} className="rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-[11px] font-semibold text-orange-100 transition hover:border-orange-300/40 disabled:cursor-not-allowed disabled:border-slate-800 disabled:bg-slate-900 disabled:text-slate-600" disabled={allVariablesSelected || filteredVariables.length === 0}>
|
|
||||||
Seleccionar todas
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{filteredVariables.length === 0 ? <p className="text-sm text-slate-500">No hay variables para la frecuencia seleccionada.</p> : null}
|
{(selectedWell?.variables ?? []).map((variable) => {
|
||||||
{filteredVariables.map((variable) => {
|
const isSelected = selectedAliases.includes(variable.alias)
|
||||||
const isSelected = selectedVariableIds.includes(variable.id)
|
|
||||||
const points = historyPointsByAlias.get(variable.alias) ?? 0
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={variable.id}
|
key={variable.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleVariable(variable.id)}
|
onClick={() => toggleAlias(variable.alias)}
|
||||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition ${isSelected ? 'border-cyan-300/50 bg-cyan-300/15 text-cyan-50' : 'border-[#2a3038] bg-[#0b0f14] text-slate-400 hover:border-slate-600'}`}
|
className={`rounded-full border px-3 py-1.5 text-xs font-semibold transition ${isSelected ? 'border-cyan-300/50 bg-cyan-300/15 text-cyan-50' : 'border-[#2a3038] bg-[#0b0f14] text-slate-400 hover:border-slate-600'}`}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
<span>{variable.label}</span>
|
{variable.label}
|
||||||
<FrequencyPill variable={variable} />
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${points > 0 ? 'bg-emerald-400/10 text-emerald-200' : 'bg-slate-800 text-slate-500'}`}>{points > 0 ? 'con histórico' : 'sin histórico'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{duplicateAliases.length > 0 ? (
|
|
||||||
<p className="mt-3 rounded-xl border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
|
||||||
Alias duplicado en este pozo: {duplicateAliases.join(', ')}. La selección visual usa cada variable por separado, pero el histórico del backend llega por alias y no puede distinguirlas completamente.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isConfigLoading ? (
|
{isConfigLoading ? (
|
||||||
@@ -440,16 +226,14 @@ export function TrendsView() {
|
|||||||
) : !resolvedProjectId ? (
|
) : !resolvedProjectId ? (
|
||||||
<TrendsMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver tendencias reales." />
|
<TrendsMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver tendencias reales." />
|
||||||
) : wells.length === 0 ? (
|
) : wells.length === 0 ? (
|
||||||
unidentifiedWells.length > 0
|
<TrendsMessage title="Sin pozos con variables" description="No hay variables configuradas en assets de tipo POZO para este proyecto." />
|
||||||
? <TrendsMessage title="Sin pozos válidos para tendencias" description="Hay assets de tipo POZO, pero no tienen nombre ni código. No se muestran como pozos válidos hasta corregir su identificación." />
|
|
||||||
: <TrendsMessage title="Sin pozos con variables" description="No hay variables configuradas en assets de tipo POZO para este proyecto." />
|
|
||||||
) : selectedVariables.length === 0 ? (
|
) : selectedVariables.length === 0 ? (
|
||||||
<TrendsMessage title="Sin variables seleccionadas" description="Seleccioná una o más variables para consultar el historial real." />
|
<TrendsMessage title="Sin variables seleccionadas" description="Seleccioná una o más variables para consultar el historial real." />
|
||||||
) : isHistoryLoading ? (
|
) : isHistoryLoading ? (
|
||||||
<TrendsMessage title="Cargando historial real" description="Consultando muestras históricas del pozo seleccionado." />
|
<TrendsMessage title="Cargando historial real" description="Consultando muestras históricas del pozo seleccionado." />
|
||||||
) : !history || (history.records ?? []).length === 0 ? (
|
) : !history || (history.records ?? []).length === 0 ? (
|
||||||
<TrendsMessage title="Sin historial" description="No hay muestras históricas para este pozo en el rango seleccionado. Probá otro rango o revisá si el pozo ya recibió datos, si las variables tienen muestras y si las capturas PWA están asociadas al asset correcto." />
|
<TrendsMessage title="Sin historial" description="El backend no devolvió muestras históricas para el pozo seleccionado." />
|
||||||
) : selectedVariablesWithHistory.length === 0 ? (
|
) : seriesWithSamples.length === 0 ? (
|
||||||
<TrendsMessage title="Sin muestras para las variables" description="El historial existe, pero no contiene valores numéricos para las variables seleccionadas." />
|
<TrendsMessage title="Sin muestras para las variables" description="El historial existe, pero no contiene valores numéricos para las variables seleccionadas." />
|
||||||
) : (
|
) : (
|
||||||
<section aria-label={`Comparativo de variables de ${selectedWell.name}`} className="relative">
|
<section aria-label={`Comparativo de variables de ${selectedWell.name}`} className="relative">
|
||||||
@@ -457,62 +241,31 @@ export function TrendsView() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-emerald-200">{selectedWell.name}</p>
|
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-emerald-200">{selectedWell.name}</p>
|
||||||
<h2 className="text-base font-semibold text-white">Comparativo de variables</h2>
|
<h2 className="text-base font-semibold text-white">Comparativo de variables</h2>
|
||||||
<p className="text-xs text-slate-500">{seriesWithSamples.length} visibles de {selectedVariablesWithHistory.length} variables con muestras reales · {selectedRange.label}</p>
|
<p className="text-xs text-slate-500">{seriesWithSamples.length} de {variableCards.length} variables con muestras reales · {selectedRange.label}</p>
|
||||||
</div>
|
</div>
|
||||||
{hasOnePointSeries ? <span className="rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-xs font-semibold text-orange-100">Más muestras para tendencia</span> : null}
|
{hasOnePointSeries ? <span className="rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-xs font-semibold text-orange-100">Más muestras para tendencia</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<TrendSummaryPanel series={seriesWithSamples} rangeLabel={selectedRange.label} />
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{variableCards.map((series) => <TrendVariableCard key={series.id} series={series} onOpenDetail={openDetail} />)}
|
{variableCards.map((series) => <TrendVariableCard key={series.alias} series={series} />)}
|
||||||
</div>
|
</div>
|
||||||
<TrendPagination page={safeTrendPage} totalPages={totalTrendPages} onPageChange={setTrendPage} />
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{selectedDetailVariable && detailSeries ? (
|
|
||||||
<TrendVariableDetailModal
|
|
||||||
series={detailSeries}
|
|
||||||
wellName={selectedWell?.name ?? selectedDetailVariable.category}
|
|
||||||
rangeIndex={detailRangeIndex}
|
|
||||||
onRangeChange={setDetailRangeIndex}
|
|
||||||
onClose={closeDetail}
|
|
||||||
isLoading={isDetailHistoryLoading}
|
|
||||||
error={detailHistoryError}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FrequencyPill({ variable }: { variable: TrendVariableOption }) {
|
|
||||||
const label = variable.frequencyClass === 'HF' ? 'HF' : variable.frequencyClass === 'LF' ? 'LF' : 'unknown'
|
|
||||||
const className = variable.frequencyClass === 'HF'
|
|
||||||
? 'bg-orange-400/10 text-orange-200'
|
|
||||||
: variable.frequencyClass === 'LF'
|
|
||||||
? 'bg-emerald-400/10 text-emerald-200'
|
|
||||||
: 'bg-slate-800 text-slate-500'
|
|
||||||
|
|
||||||
return <span className={`rounded-full px-2 py-0.5 text-[10px] ${className}`}>{label}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value: number | null | undefined, unit?: string) {
|
function formatValue(value: number | null | undefined, unit?: string) {
|
||||||
if (value === null || value === undefined) return 'Sin dato'
|
if (value === null || value === undefined) return 'Sin dato'
|
||||||
const formatted = Number(value.toFixed(3)).toString()
|
const formatted = Number(value.toFixed(3)).toString()
|
||||||
return unit ? `${formatted} ${unit}` : formatted
|
return unit ? `${formatted} ${unit}` : formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFrequency(pollingIntervalMs?: number, fallbackLabel?: string) {
|
function formatFrequency(pollingIntervalMs?: number) {
|
||||||
if (fallbackLabel) return fallbackLabel.toLowerCase()
|
|
||||||
if (!pollingIntervalMs) return 'frecuencia no configurada'
|
if (!pollingIntervalMs) return 'frecuencia no configurada'
|
||||||
if (pollingIntervalMs < 60_000) return `cada ${Math.round(pollingIntervalMs / 1000)} s`
|
if (pollingIntervalMs < 60_000) return `cada ${Math.round(pollingIntervalMs / 1000)} s`
|
||||||
return `cada ${Math.round(pollingIntervalMs / 60_000)} min`
|
return `cada ${Math.round(pollingIntervalMs / 60_000)} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFrequencyClass(frequencyClass: TrendMultiSeries['frequencyClass']) {
|
|
||||||
if (frequencyClass === 'HF') return 'Alta frecuencia'
|
|
||||||
if (frequencyClass === 'LF') return 'Baja frecuencia'
|
|
||||||
return 'Frecuencia desconocida'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDelta(points: TrendMultiSeries['points']) {
|
function buildDelta(points: TrendMultiSeries['points']) {
|
||||||
if (points.length < 2) return null
|
if (points.length < 2) return null
|
||||||
const latest = points.at(-1)?.value
|
const latest = points.at(-1)?.value
|
||||||
@@ -521,91 +274,22 @@ function buildDelta(points: TrendMultiSeries['points']) {
|
|||||||
return ((latest - previous) / Math.abs(previous)) * 100
|
return ((latest - previous) / Math.abs(previous)) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
function average(values: number[]) {
|
function TrendVariableCard({ series }: { series: TrendMultiSeries }) {
|
||||||
if (values.length === 0) return null
|
|
||||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeriesStats(series: TrendMultiSeries) {
|
|
||||||
const values = series.points.map((point) => point.value)
|
const values = series.points.map((point) => point.value)
|
||||||
const latestPoint = series.points.at(-1)
|
const latest = series.points.at(-1)?.value
|
||||||
|
|
||||||
return {
|
|
||||||
latestPoint,
|
|
||||||
min: values.length ? Math.min(...values) : null,
|
|
||||||
max: values.length ? Math.max(...values) : null,
|
|
||||||
avg: average(values),
|
|
||||||
count: series.points.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendSummaryPanel({ series, rangeLabel }: { series: TrendMultiSeries[]; rangeLabel: string }) {
|
|
||||||
return (
|
|
||||||
<section aria-label="Resumen estadístico de tendencias" className="mb-4 rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/90 p-4 shadow-xl shadow-black/25">
|
|
||||||
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-200">Resumen operativo</p>
|
|
||||||
<h3 className="text-base font-semibold text-white">Estadísticas con histórico real</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">Rango consultado: <span className="font-mono text-slate-300">{rangeLabel}</span></p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
|
||||||
{series.map((serie) => <TrendSummaryCard key={serie.id} series={serie} rangeLabel={rangeLabel} />)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendSummaryCard({ series, rangeLabel }: { series: TrendMultiSeries; rangeLabel: string }) {
|
|
||||||
const values = series.points.map((point) => point.value)
|
|
||||||
const latestPoint = series.points.at(-1)
|
|
||||||
const min = values.length ? Math.min(...values) : null
|
const min = values.length ? Math.min(...values) : null
|
||||||
const max = values.length ? Math.max(...values) : null
|
const max = values.length ? Math.max(...values) : null
|
||||||
const avg = average(values)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="rounded-2xl border border-[#2a3038] bg-[#0b0f14]/90 p-3">
|
|
||||||
<div className="mb-3 flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-white">{series.label}</h4>
|
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500">{series.alias} · {series.frequencyClass}</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-cyan-300/20 bg-cyan-300/10 px-2 py-0.5 text-[10px] font-semibold text-cyan-100">{rangeLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
|
|
||||||
<TrendMiniStat label="Último" value={formatValue(latestPoint?.value, series.unit)} />
|
|
||||||
<TrendMiniStat label="Mín" value={formatValue(min, series.unit)} />
|
|
||||||
<TrendMiniStat label="Máx" value={formatValue(max, series.unit)} />
|
|
||||||
<TrendMiniStat label="Prom" value={formatValue(avg, series.unit)} />
|
|
||||||
<TrendMiniStat label="Muestras" value={series.points.length.toString()} />
|
|
||||||
<TrendMiniStat label="Última muestra" value={latestPoint?.label ?? 'Sin dato'} />
|
|
||||||
<TrendMiniStat label="Dispositivo" value={series.deviceName} />
|
|
||||||
<TrendMiniStat label="Frecuencia" value={formatFrequency(series.pollingIntervalMs, series.frequencyLabel)} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendVariableCard({ series, onOpenDetail }: { series: TrendMultiSeries; onOpenDetail: (series: TrendMultiSeries) => void }) {
|
|
||||||
const { latestPoint, min, max } = getSeriesStats(series)
|
|
||||||
const delta = buildDelta(series.points)
|
const delta = buildDelta(series.points)
|
||||||
const gradientId = `trend-fill-${series.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<article className="overflow-hidden rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/90 shadow-xl shadow-black/25">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenDetail(series)}
|
|
||||||
className="block w-full cursor-pointer overflow-hidden rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/90 text-left shadow-xl shadow-black/25 transition hover:border-emerald-300/40 hover:bg-[#151b23]/95 focus:outline-none focus:ring-2 focus:ring-emerald-300/70 focus:ring-offset-2 focus:ring-offset-[#090b0f]"
|
|
||||||
aria-label={`Abrir detalle histórico de ${series.label}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-white">{series.label}</h3>
|
<h3 className="text-base font-semibold text-white">{series.label}</h3>
|
||||||
<p className="mt-1 text-xs text-slate-500">{series.category} · {formatFrequency(series.pollingIntervalMs, series.frequencyLabel)}</p>
|
<p className="mt-1 text-xs text-slate-500">{series.category} · {formatFrequency(series.pollingIntervalMs)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left sm:text-right">
|
<div className="text-left sm:text-right">
|
||||||
<p className="font-mono text-2xl font-semibold tabular-nums text-white">{formatValue(latestPoint?.value, series.unit)}</p>
|
<p className="font-mono text-2xl font-semibold tabular-nums text-white">{formatValue(latest, series.unit)}</p>
|
||||||
{delta === null ? (
|
{delta === null ? (
|
||||||
<p className="mt-1 text-xs font-medium text-slate-500">Más muestras para tendencia</p>
|
<p className="mt-1 text-xs font-medium text-slate-500">Más muestras para tendencia</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -629,7 +313,7 @@ function TrendVariableCard({ series, onOpenDetail }: { series: TrendMultiSeries;
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={series.points}>
|
<AreaChart data={series.points}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`trend-fill-${series.alias}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={series.color} stopOpacity={0.45} />
|
<stop offset="5%" stopColor={series.color} stopOpacity={0.45} />
|
||||||
<stop offset="95%" stopColor={series.color} stopOpacity={0.03} />
|
<stop offset="95%" stopColor={series.color} stopOpacity={0.03} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@@ -637,145 +321,16 @@ function TrendVariableCard({ series, onOpenDetail }: { series: TrendMultiSeries;
|
|||||||
<XAxis dataKey="label" stroke="rgba(209, 250, 229, 0.36)" fontSize={10} tickLine={false} axisLine={false} minTickGap={24} />
|
<XAxis dataKey="label" stroke="rgba(209, 250, 229, 0.36)" fontSize={10} tickLine={false} axisLine={false} minTickGap={24} />
|
||||||
<YAxis stroke="rgba(209, 250, 229, 0.28)" fontSize={10} tickLine={false} axisLine={false} width={42} />
|
<YAxis stroke="rgba(209, 250, 229, 0.28)" fontSize={10} tickLine={false} axisLine={false} width={42} />
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#0b0f14', border: '1px solid rgba(42, 48, 56, 0.95)', borderRadius: '12px', color: '#d1fae5' }} formatter={(value) => [formatValue(Number(value), series.unit), series.label]} labelStyle={{ color: '#a7f3d0' }} />
|
<Tooltip contentStyle={{ backgroundColor: '#0b0f14', border: '1px solid rgba(42, 48, 56, 0.95)', borderRadius: '12px', color: '#d1fae5' }} formatter={(value) => [formatValue(Number(value), series.unit), series.label]} labelStyle={{ color: '#a7f3d0' }} />
|
||||||
{min !== null ? <ReferenceLine y={min} stroke="rgba(125, 211, 252, 0.72)" strokeDasharray="4 4" label={{ value: `Mín ${formatValue(min, series.unit)}`, fill: '#bae6fd', fontSize: 10, position: 'insideTopLeft' }} /> : null}
|
<Area type="linear" dataKey="value" stroke={series.color} fill={`url(#trend-fill-${series.alias})`} strokeWidth={2.5} dot={series.points.length === 1 ? { r: 4, fill: series.color } : false} activeDot={{ r: 5, fill: '#ecfdf5' }} isAnimationActive={false} />
|
||||||
{max !== null && max !== min ? <ReferenceLine y={max} stroke="rgba(251, 146, 60, 0.72)" strokeDasharray="4 4" label={{ value: `Máx ${formatValue(max, series.unit)}`, fill: '#fed7aa', fontSize: 10, position: 'insideTopRight' }} /> : null}
|
|
||||||
<Area type="linear" dataKey="value" stroke={series.color} fill={`url(#${gradientId})`} strokeWidth={2.5} dot={series.points.length === 1 ? { r: 4, fill: series.color } : false} activeDot={{ r: 5, fill: '#ecfdf5' }} isAnimationActive={false} />
|
|
||||||
<Line type="linear" dataKey="value" stroke={series.color} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
<Line type="linear" dataKey="value" stroke={series.color} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrendVariableDetailModal({
|
|
||||||
series,
|
|
||||||
wellName,
|
|
||||||
rangeIndex,
|
|
||||||
onRangeChange,
|
|
||||||
onClose,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
series: TrendMultiSeries
|
|
||||||
wellName: string
|
|
||||||
rangeIndex: number
|
|
||||||
onRangeChange: (index: number) => void
|
|
||||||
onClose: () => void
|
|
||||||
isLoading: boolean
|
|
||||||
error: string | null
|
|
||||||
}) {
|
|
||||||
const { latestPoint, min, max, avg, count } = getSeriesStats(series)
|
|
||||||
const gradientId = `trend-detail-fill-${series.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/75 p-0 backdrop-blur-sm sm:items-center sm:p-4" role="presentation">
|
|
||||||
<section role="dialog" aria-modal="true" aria-labelledby="trend-detail-title" className="max-h-[96vh] w-full overflow-hidden rounded-t-[2rem] border border-[#2a3038] bg-[#090b0f] text-slate-100 shadow-2xl shadow-black/60 sm:max-w-6xl sm:rounded-[2rem]">
|
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-[#242a31] bg-[#11151b] p-4 sm:p-5">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-emerald-200">Detalle histórico</p>
|
|
||||||
<h2 id="trend-detail-title" className="mt-1 text-xl font-semibold text-white sm:text-2xl">{series.label}</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">{series.alias} · {wellName}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={onClose} className="rounded-full border border-slate-700 bg-slate-950 p-2 text-slate-300 transition hover:border-emerald-300/50 hover:text-white focus:outline-none focus:ring-2 focus:ring-emerald-300" aria-label="Cerrar detalle histórico">
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[calc(96vh-5rem)] overflow-y-auto p-4 sm:p-5">
|
|
||||||
<div className="mb-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-start">
|
|
||||||
<div className="grid gap-2 text-xs sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<TrendMiniStat label="Pozo" value={wellName} />
|
|
||||||
<TrendMiniStat label="Frecuencia" value={formatFrequencyClass(series.frequencyClass)} />
|
|
||||||
<TrendMiniStat label="Unidad" value={series.unit ?? 'Sin unidad'} />
|
|
||||||
<TrendMiniStat label="Dispositivo" value={series.deviceName || 'No disponible'} />
|
|
||||||
<TrendMiniStat label="Último" value={formatValue(latestPoint?.value, series.unit)} />
|
|
||||||
<TrendMiniStat label="Mín" value={formatValue(min, series.unit)} />
|
|
||||||
<TrendMiniStat label="Máx" value={formatValue(max, series.unit)} />
|
|
||||||
<TrendMiniStat label="Promedio" value={formatValue(avg, series.unit)} />
|
|
||||||
<TrendMiniStat label="Muestras" value={count.toString()} />
|
|
||||||
<TrendMiniStat label="Última muestra" value={latestPoint?.label ?? 'Sin dato'} />
|
|
||||||
<TrendMiniStat label="Calidad/estado" value="No disponible" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[#2a3038] bg-[#0b0f14] p-2">
|
|
||||||
<div className="grid grid-cols-4 gap-1" role="group" aria-label="Rango de detalle histórico">
|
|
||||||
{rangeOptions.map((option, index) => (
|
|
||||||
<button
|
|
||||||
key={option.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRangeChange(index)}
|
|
||||||
className={`rounded-xl px-3 py-2 text-xs font-semibold transition ${rangeIndex === index ? 'bg-emerald-400 text-slate-950' : 'text-slate-400 hover:bg-slate-800 hover:text-white'}`}
|
|
||||||
aria-pressed={rangeIndex === index}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? <p className="mb-3 rounded-xl border border-cyan-300/20 bg-cyan-300/10 px-3 py-2 text-xs font-semibold text-cyan-100">Actualizando histórico...</p> : null}
|
|
||||||
{error ? <p className="mb-3 rounded-xl border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">{error}</p> : null}
|
|
||||||
|
|
||||||
<div className="h-[58vh] min-h-[22rem] rounded-[1.5rem] border border-[#252b33] bg-[#0b0f14] p-3 sm:p-4">
|
|
||||||
{series.points.length === 0 ? (
|
|
||||||
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-emerald-300/10 text-center text-sm text-slate-500">Sin histórico disponible para este rango.</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={series.points}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={series.color} stopOpacity={0.5} />
|
|
||||||
<stop offset="95%" stopColor={series.color} stopOpacity={0.04} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<XAxis dataKey="label" stroke="rgba(209, 250, 229, 0.42)" fontSize={11} tickLine={false} axisLine={false} minTickGap={32} />
|
|
||||||
<YAxis stroke="rgba(209, 250, 229, 0.34)" fontSize={11} tickLine={false} axisLine={false} width={54} label={series.unit ? { value: series.unit, angle: -90, position: 'insideLeft', fill: '#94a3b8', fontSize: 11 } : undefined} />
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#0b0f14', border: '1px solid rgba(42, 48, 56, 0.95)', borderRadius: '12px', color: '#d1fae5' }} formatter={(value) => [formatValue(Number(value), series.unit), series.label]} labelFormatter={(_, payload) => payload?.[0]?.payload?.time ?? ''} labelStyle={{ color: '#a7f3d0' }} />
|
|
||||||
{min !== null ? <ReferenceLine y={min} stroke="rgba(125, 211, 252, 0.72)" strokeDasharray="4 4" label={{ value: `Mín ${formatValue(min, series.unit)}`, fill: '#bae6fd', fontSize: 11, position: 'insideTopLeft' }} /> : null}
|
|
||||||
{max !== null && max !== min ? <ReferenceLine y={max} stroke="rgba(251, 146, 60, 0.72)" strokeDasharray="4 4" label={{ value: `Máx ${formatValue(max, series.unit)}`, fill: '#fed7aa', fontSize: 11, position: 'insideTopRight' }} /> : null}
|
|
||||||
<Area type="linear" dataKey="value" stroke={series.color} fill={`url(#${gradientId})`} strokeWidth={3} dot={series.points.length === 1 ? { r: 4, fill: series.color } : false} activeDot={{ r: 5, fill: '#ecfdf5' }} isAnimationActive={false} />
|
|
||||||
<Line type="linear" dataKey="value" stroke={series.color} strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendPagination({ page, totalPages, onPageChange }: { page: number; totalPages: number; onPageChange: (page: number) => void }) {
|
|
||||||
if (totalPages <= 1) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav aria-label="Paginación de variables de tendencias" className="mt-4 flex flex-col gap-2 rounded-2xl border border-[#252b33] bg-[#11151b]/90 p-3 text-xs text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<p className="font-semibold text-slate-300">Página {page} de {totalPages}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPageChange(page - 1)}
|
|
||||||
disabled={page <= 1}
|
|
||||||
className="rounded-full border border-[#2a3038] bg-[#0b0f14] px-3 py-1.5 font-semibold text-slate-200 transition hover:border-emerald-300/50 disabled:cursor-not-allowed disabled:border-slate-800 disabled:text-slate-600"
|
|
||||||
>
|
|
||||||
Anterior
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPageChange(page + 1)}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
className="rounded-full border border-[#2a3038] bg-[#0b0f14] px-3 py-1.5 font-semibold text-slate-200 transition hover:border-emerald-300/50 disabled:cursor-not-allowed disabled:border-slate-800 disabled:text-slate-600"
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercent(value: number) {
|
function formatPercent(value: number) {
|
||||||
return `${Number(value.toFixed(1)).toString()}%`
|
return `${Number(value.toFixed(1)).toString()}%`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
import { getProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
||||||
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
||||||
import { fetchDashboardSummary } from '../api/dashboard-api'
|
import { fetchAssetTelemetryHistory, fetchDashboardSummary } from '../api/dashboard-api'
|
||||||
import { VariablesView } from './VariablesView'
|
import { VariablesView } from './VariablesView'
|
||||||
|
|
||||||
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
||||||
@@ -17,6 +17,7 @@ vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
|||||||
|
|
||||||
vi.mock('../api/dashboard-api', () => ({
|
vi.mock('../api/dashboard-api', () => ({
|
||||||
fetchDashboardSummary: vi.fn(),
|
fetchDashboardSummary: vi.fn(),
|
||||||
|
fetchAssetTelemetryHistory: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
@@ -56,26 +57,6 @@ function projectConfigWithVariables() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectConfigWithVariableCount(count: number) {
|
|
||||||
const config = projectConfigWithVariables()
|
|
||||||
config.assets[0].devices[0].variables = Array.from({ length: count }, (_, index) => ({
|
|
||||||
id: `var-${index + 1}`,
|
|
||||||
device_id: 'device-1',
|
|
||||||
address: String(40001 + index),
|
|
||||||
data_type: 'float32',
|
|
||||||
alias: `TAG_${String(index + 1).padStart(2, '0')}`,
|
|
||||||
unit: 'psi',
|
|
||||||
polling_interval_ms: index % 2 === 0 ? 5000 : 60000,
|
|
||||||
alarm_enabled: false,
|
|
||||||
byte_order: 'ABCD',
|
|
||||||
metadata: { label: `Variable ${String(index + 1).padStart(2, '0')}`, category: index % 2 === 0 ? 'Alta' : 'Baja' },
|
|
||||||
is_enabled: true,
|
|
||||||
created_at: '2026-06-22T00:00:00Z',
|
|
||||||
updated_at: '2026-06-22T00:00:00Z',
|
|
||||||
}))
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyProjectConfig() {
|
function emptyProjectConfig() {
|
||||||
return {
|
return {
|
||||||
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
@@ -135,6 +116,7 @@ describe('VariablesView', () => {
|
|||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
||||||
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { variables: { PRESION_CABEZA: { value: 120, quality: 'GOOD' } } } }],
|
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { variables: { PRESION_CABEZA: { value: 120, quality: 'GOOD' } } } }],
|
||||||
})
|
})
|
||||||
|
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [] })
|
||||||
vi.mocked(getAlarms).mockResolvedValue([])
|
vi.mocked(getAlarms).mockResolvedValue([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,8 +143,6 @@ describe('VariablesView', () => {
|
|||||||
expect(container.textContent).toContain('Cargando variables reales')
|
expect(container.textContent).toContain('Cargando variables reales')
|
||||||
expect(container.textContent).not.toContain('Sin variables reales')
|
expect(container.textContent).not.toContain('Sin variables reales')
|
||||||
expect(container.textContent).not.toContain('0 de 0 variables')
|
expect(container.textContent).not.toContain('0 de 0 variables')
|
||||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(20)
|
|
||||||
expect(container.querySelector('input')?.disabled).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps previously loaded real variables visible during background refresh and refresh errors', async () => {
|
it('keeps previously loaded real variables visible during background refresh and refresh errors', async () => {
|
||||||
@@ -214,99 +194,7 @@ describe('VariablesView', () => {
|
|||||||
|
|
||||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(0)
|
expect(container.querySelectorAll('tbody tr')).toHaveLength(0)
|
||||||
expect(container.textContent).not.toContain('PRESION_CABEZA')
|
expect(container.textContent).not.toContain('PRESION_CABEZA')
|
||||||
})
|
expect(fetchAssetTelemetryHistory).not.toHaveBeenCalled()
|
||||||
|
|
||||||
it('keeps the variables table focused on current operational data without a trend column', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue(projectConfigWithVariables())
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const headers = Array.from(container.querySelectorAll('thead th')).map((header) => header.textContent)
|
|
||||||
expect(headers).toEqual(['TAG', 'VARIABLE', 'POZO', 'FRECUENCIA', 'VALOR', 'CALIDAD', 'ESTADO', 'ACTUALIZADO'])
|
|
||||||
expect(headers).not.toContain('TENDENCIA')
|
|
||||||
expect(container.textContent).not.toContain('Sin datos')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders blob-only LF capture fields alongside catalog variables', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue(projectConfigWithVariables())
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'PWA',
|
|
||||||
frequency_class: 'LF',
|
|
||||||
last_updated: '2026-06-22T10:00:00Z',
|
|
||||||
telemetry: { values: { PRESION_CABEZA: 120, caudal_bopd: 88 }, source: 'PWA', frequency_class: 'LF' },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('3 de 3 variables')
|
|
||||||
expect(container.textContent).toContain('Caudal bopd')
|
|
||||||
expect(container.textContent).toContain('Captura PWA')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia')
|
|
||||||
expect(container.textContent).toContain('88 BPD')
|
|
||||||
expect(container.textContent).toContain('BAJA')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders fixed frequency options and filters Alta/Baja/Todas', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue(projectConfigWithVariables())
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
const frequencySelect = Array.from(container.querySelectorAll('select'))[2] as HTMLSelectElement
|
|
||||||
expect(Array.from(frequencySelect.options).map((option) => option.textContent)).toEqual(['Todas', 'Alta', 'Baja'])
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
frequencySelect.value = 'low'
|
|
||||||
frequencySelect.dispatchEvent(new Event('change', { bubbles: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('0 de 2 variables')
|
|
||||||
expect(container.textContent).toContain('Sin coincidencias')
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
frequencySelect.value = 'all'
|
|
||||||
frequencySelect.dispatchEvent(new Event('change', { bubbles: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('2 de 2 variables')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders only 20 rows per page and disables pagination buttons at boundaries', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue(projectConfigWithVariableCount(25))
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(20)
|
|
||||||
expect(container.textContent).toContain('Página 1 de 2')
|
|
||||||
const previousButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Anterior') as HTMLButtonElement
|
|
||||||
const nextButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Siguiente') as HTMLButtonElement
|
|
||||||
expect(previousButton.disabled).toBe(true)
|
|
||||||
expect(nextButton.disabled).toBe(false)
|
|
||||||
|
|
||||||
await act(async () => nextButton.click())
|
|
||||||
|
|
||||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(5)
|
|
||||||
expect(container.textContent).toContain('Página 2 de 2')
|
|
||||||
expect(previousButton.disabled).toBe(false)
|
|
||||||
expect(nextButton.disabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clears the initial load error detail when no real data exists yet', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockRejectedValue(new Error('network down'))
|
|
||||||
|
|
||||||
await renderView()
|
|
||||||
await flushEffects()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('No se pudo cargar la vista')
|
|
||||||
expect(container.textContent).not.toContain('No se pudo cargar la configuración real de variables')
|
|
||||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not leak previously loaded variables while another project is loading', async () => {
|
it('does not leak previously loaded variables while another project is loading', async () => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { AlertTriangle, RotateCcw, Search, SlidersHorizontal, Table2 } from 'lucide-react'
|
import { AlertTriangle, RotateCcw, Search, SlidersHorizontal, Table2 } from 'lucide-react'
|
||||||
import { fetchDashboardSummary } from '../api/dashboard-api'
|
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryResponse } from '../api/dashboard-api'
|
||||||
import { buildVariableExplorerRows, filterVariableExplorerRows, type VariableExplorerFilters, type VariableExplorerRow, type VariableStatus } from '../utils/variables-view-model'
|
import { buildVariableExplorerRows, filterVariableExplorerRows, type VariableExplorerFilters, type VariableExplorerRow, type VariableStatus } from '../utils/variables-view-model'
|
||||||
|
import { buildTrendSeries, type TrendSeriesPoint } from '../utils/trends-view-model'
|
||||||
import { getAlarms, type AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
|
import { getAlarms, type AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
|
||||||
import { getProjectFullConfig, type EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
import { getProjectFullConfig, type EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
@@ -24,7 +25,13 @@ const valueClassName: Record<VariableStatus, string> = {
|
|||||||
'no-data': 'text-slate-500',
|
'no-data': 'text-slate-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const trendColor: Record<VariableStatus, string> = {
|
||||||
|
disabled: '#64748b',
|
||||||
|
alarm: '#f87171',
|
||||||
|
'out-of-range': '#fb923c',
|
||||||
|
normal: '#34d399',
|
||||||
|
'no-data': '#64748b',
|
||||||
|
}
|
||||||
|
|
||||||
const defaultFilters: VariableExplorerFilters = {
|
const defaultFilters: VariableExplorerFilters = {
|
||||||
search: '',
|
search: '',
|
||||||
@@ -38,12 +45,13 @@ type VariablesSnapshot = {
|
|||||||
projectId: string
|
projectId: string
|
||||||
config: EdgeProjectFullConfig
|
config: EdgeProjectFullConfig
|
||||||
telemetry: AssetData[]
|
telemetry: AssetData[]
|
||||||
|
historyByAssetId: Record<string, TelemetryHistoryResponse | null>
|
||||||
alarms: AlarmEvent[]
|
alarms: AlarmEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type VariablesLoadState = 'idle' | 'initial-loading' | 'refreshing' | 'loaded' | 'empty' | 'error'
|
type VariablesLoadState = 'idle' | 'initial-loading' | 'refreshing' | 'loaded' | 'empty' | 'error'
|
||||||
|
|
||||||
function uniqueOptions(rows: VariableExplorerRow[], key: keyof Pick<VariableExplorerRow, 'well' | 'category'>) {
|
function uniqueOptions(rows: VariableExplorerRow[], key: keyof Pick<VariableExplorerRow, 'well' | 'category' | 'frequency'>) {
|
||||||
return Array.from(new Set(rows.map((row) => row[key]).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'es'))
|
return Array.from(new Set(rows.map((row) => row[key]).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'es'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +62,6 @@ export function VariablesView() {
|
|||||||
const [loadState, setLoadState] = useState<VariablesLoadState>('idle')
|
const [loadState, setLoadState] = useState<VariablesLoadState>('idle')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [filters, setFilters] = useState<VariableExplorerFilters>(defaultFilters)
|
const [filters, setFilters] = useState<VariableExplorerFilters>(defaultFilters)
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const latestRequestIdRef = useRef(0)
|
const latestRequestIdRef = useRef(0)
|
||||||
const snapshotRef = useRef<VariablesSnapshot | null>(null)
|
const snapshotRef = useRef<VariablesSnapshot | null>(null)
|
||||||
|
|
||||||
@@ -93,11 +100,25 @@ export function VariablesView() {
|
|||||||
fetchDashboardSummary(),
|
fetchDashboardSummary(),
|
||||||
getAlarms(requestProjectId),
|
getAlarms(requestProjectId),
|
||||||
])
|
])
|
||||||
|
const wellAssetIds = fullConfig.assets
|
||||||
|
.filter(({ asset }) => asset.asset_type === 'POZO')
|
||||||
|
.map(({ asset }) => asset.id)
|
||||||
|
const historyEntries = await Promise.all(wellAssetIds.map(async (assetId) => {
|
||||||
|
try {
|
||||||
|
const history = await fetchAssetTelemetryHistory(assetId, 1, 48)
|
||||||
|
return [assetId, history] as const
|
||||||
|
} catch (historyError) {
|
||||||
|
console.warn('Variables trend history error:', historyError)
|
||||||
|
return [assetId, null] as const
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
if (!mounted || latestRequestIdRef.current !== requestId) return
|
if (!mounted || latestRequestIdRef.current !== requestId) return
|
||||||
const nextSnapshot: VariablesSnapshot = {
|
const nextSnapshot: VariablesSnapshot = {
|
||||||
projectId: requestProjectId,
|
projectId: requestProjectId,
|
||||||
config: fullConfig,
|
config: fullConfig,
|
||||||
telemetry: summary.data || [],
|
telemetry: summary.data || [],
|
||||||
|
historyByAssetId: Object.fromEntries(historyEntries),
|
||||||
alarms: activeAlarms,
|
alarms: activeAlarms,
|
||||||
}
|
}
|
||||||
const nextRows = buildVariableExplorerRows(nextSnapshot.config, nextSnapshot.telemetry, nextSnapshot.alarms)
|
const nextRows = buildVariableExplorerRows(nextSnapshot.config, nextSnapshot.telemetry, nextSnapshot.alarms)
|
||||||
@@ -105,15 +126,10 @@ export function VariablesView() {
|
|||||||
snapshotRef.current = nextSnapshot
|
snapshotRef.current = nextSnapshot
|
||||||
setSnapshot(nextSnapshot)
|
setSnapshot(nextSnapshot)
|
||||||
setLoadState(nextRows.length === 0 ? 'empty' : 'loaded')
|
setLoadState(nextRows.length === 0 ? 'empty' : 'loaded')
|
||||||
setError(null)
|
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!mounted || latestRequestIdRef.current !== requestId) return
|
if (!mounted || latestRequestIdRef.current !== requestId) return
|
||||||
void loadError
|
void loadError
|
||||||
if (snapshotRef.current?.projectId === requestProjectId) {
|
setError('No se pudo cargar la configuración real de variables.')
|
||||||
setError('No se pudo actualizar la configuración real de variables.')
|
|
||||||
} else {
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
setLoadState('error')
|
setLoadState('error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,12 +148,7 @@ export function VariablesView() {
|
|||||||
const filteredRows = useMemo(() => filterVariableExplorerRows(rows, filters), [rows, filters])
|
const filteredRows = useMemo(() => filterVariableExplorerRows(rows, filters), [rows, filters])
|
||||||
const wellOptions = useMemo(() => uniqueOptions(rows, 'well'), [rows])
|
const wellOptions = useMemo(() => uniqueOptions(rows, 'well'), [rows])
|
||||||
const categoryOptions = useMemo(() => uniqueOptions(rows, 'category'), [rows])
|
const categoryOptions = useMemo(() => uniqueOptions(rows, 'category'), [rows])
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE))
|
const frequencyOptions = useMemo(() => uniqueOptions(rows, 'frequency'), [rows])
|
||||||
const safePage = Math.min(page, totalPages)
|
|
||||||
const paginatedRows = useMemo(() => {
|
|
||||||
const start = (safePage - 1) * PAGE_SIZE
|
|
||||||
return filteredRows.slice(start, start + PAGE_SIZE)
|
|
||||||
}, [filteredRows, safePage])
|
|
||||||
const isInitialLoading = loadState === 'initial-loading' && rows.length === 0
|
const isInitialLoading = loadState === 'initial-loading' && rows.length === 0
|
||||||
const isBackgroundRefresh = loadState === 'refreshing' && rows.length > 0
|
const isBackgroundRefresh = loadState === 'refreshing' && rows.length > 0
|
||||||
const isErrorWithRows = loadState === 'error' && rows.length > 0
|
const isErrorWithRows = loadState === 'error' && rows.length > 0
|
||||||
@@ -151,14 +162,6 @@ export function VariablesView() {
|
|||||||
setFilters((current) => ({ ...current, [key]: value }))
|
setFilters((current) => ({ ...current, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
}, [filters, resolvedProjectId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > totalPages) setPage(totalPages)
|
|
||||||
}, [page, totalPages])
|
|
||||||
|
|
||||||
const hasActiveFilters = Object.entries(filters).some(([key, value]) => value !== defaultFilters[key as keyof VariableExplorerFilters])
|
const hasActiveFilters = Object.entries(filters).some(([key, value]) => value !== defaultFilters[key as keyof VariableExplorerFilters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,7 +199,7 @@ export function VariablesView() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilters(defaultFilters)}
|
onClick={() => setFilters(defaultFilters)}
|
||||||
disabled={!hasActiveFilters || isInitialLoading}
|
disabled={!hasActiveFilters}
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-[11px] font-semibold text-orange-100 transition hover:border-orange-300/40 disabled:cursor-not-allowed disabled:border-slate-800 disabled:bg-slate-900/60 disabled:text-slate-600"
|
className="inline-flex items-center gap-2 rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-[11px] font-semibold text-orange-100 transition hover:border-orange-300/40 disabled:cursor-not-allowed disabled:border-slate-800 disabled:bg-slate-900/60 disabled:text-slate-600"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" /> Limpiar
|
<RotateCcw className="h-3 w-3" /> Limpiar
|
||||||
@@ -209,37 +212,27 @@ export function VariablesView() {
|
|||||||
<input
|
<input
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(event) => setFilter('search', event.target.value)}
|
onChange={(event) => setFilter('search', event.target.value)}
|
||||||
disabled={isInitialLoading}
|
|
||||||
className="h-9 rounded-lg border border-[#2a3038] bg-[#0b0f14] py-2 pl-9 pr-3 text-sm normal-case tracking-normal text-white outline-none transition placeholder:text-slate-600 focus:border-cyan-300"
|
className="h-9 rounded-lg border border-[#2a3038] bg-[#0b0f14] py-2 pl-9 pr-3 text-sm normal-case tracking-normal text-white outline-none transition placeholder:text-slate-600 focus:border-cyan-300"
|
||||||
placeholder="Tag, variable o pozo"
|
placeholder="Tag o variable"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<FilterSelect label="Pozo" value={filters.well} onChange={(value) => setFilter('well', value)} options={wellOptions} disabled={isInitialLoading} />
|
<FilterSelect label="Pozo" value={filters.well} onChange={(value) => setFilter('well', value)} options={wellOptions} />
|
||||||
<FilterSelect label="Categoría" value={filters.category} onChange={(value) => setFilter('category', value)} options={categoryOptions} disabled={isInitialLoading} />
|
<FilterSelect label="Categoría" value={filters.category} onChange={(value) => setFilter('category', value)} options={categoryOptions} />
|
||||||
<FilterSelect
|
<FilterSelect label="Frecuencia" value={filters.frequency} onChange={(value) => setFilter('frequency', value)} options={frequencyOptions} />
|
||||||
label="Frecuencia"
|
|
||||||
value={filters.frequency}
|
|
||||||
onChange={(value) => setFilter('frequency', value)}
|
|
||||||
options={['high', 'low']}
|
|
||||||
labels={{ all: 'Todas', high: 'Alta', low: 'Baja' }}
|
|
||||||
allLabel="Todas"
|
|
||||||
disabled={isInitialLoading}
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="Estado"
|
label="Estado"
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(value) => setFilter('status', value)}
|
onChange={(value) => setFilter('status', value)}
|
||||||
options={['normal', 'alarm', 'out-of-range', 'disabled', 'no-data']}
|
options={['normal', 'alarm', 'out-of-range', 'disabled', 'no-data']}
|
||||||
labels={{ normal: 'Normal', alarm: 'Alarma activa', 'out-of-range': 'Fuera de rango', disabled: 'Deshabilitada', 'no-data': 'Sin dato' }}
|
labels={{ normal: 'Normal', alarm: 'Alarma activa', 'out-of-range': 'Fuera de rango', disabled: 'Deshabilitada', 'no-data': 'Sin dato' }}
|
||||||
disabled={isInitialLoading}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isInitialLoading ? (
|
{isInitialLoading ? (
|
||||||
<VariablesTable rows={[]} loadingRows={PAGE_SIZE} />
|
<VariablesMessage title="Cargando variables reales" description="Consultando configuración, telemetría y alarmas del proyecto." />
|
||||||
) : loadState === 'error' && rows.length === 0 ? (
|
) : loadState === 'error' && rows.length === 0 ? (
|
||||||
<VariablesMessage icon={<AlertTriangle className="h-5 w-5 text-amber-300" />} title="No se pudo cargar la vista" description={error ?? 'La estructura está lista; reintentaremos cuando el proyecto entregue datos reales.'} />
|
<VariablesMessage icon={<AlertTriangle className="h-5 w-5 text-amber-300" />} title="No se pudo cargar la vista" description={error} />
|
||||||
) : !resolvedProjectId ? (
|
) : !resolvedProjectId ? (
|
||||||
<VariablesMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver sus variables configuradas." />
|
<VariablesMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver sus variables configuradas." />
|
||||||
) : loadState === 'empty' && rows.length === 0 ? (
|
) : loadState === 'empty' && rows.length === 0 ? (
|
||||||
@@ -247,36 +240,6 @@ export function VariablesView() {
|
|||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<VariablesMessage title="Sin coincidencias" description="Los filtros actuales no coinciden con variables reales del proyecto." />
|
<VariablesMessage title="Sin coincidencias" description="Los filtros actuales no coinciden con variables reales del proyecto." />
|
||||||
) : (
|
) : (
|
||||||
<VariablesTable rows={paginatedRows}>
|
|
||||||
<div className="flex flex-col gap-2 border-t border-slate-800 bg-[#11151b] px-3 py-3 text-[11px] text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span className="font-mono uppercase tracking-[0.14em]">Página {safePage} de {totalPages}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
||||||
disabled={safePage === 1}
|
|
||||||
className="rounded border border-orange-300/25 bg-orange-400/10 px-3 py-1 font-semibold text-orange-100 transition hover:border-orange-300/50 disabled:cursor-not-allowed disabled:border-slate-800 disabled:bg-slate-900 disabled:text-slate-600"
|
|
||||||
>
|
|
||||||
Anterior
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
|
||||||
disabled={safePage === totalPages}
|
|
||||||
className="rounded border border-emerald-300/25 bg-emerald-400/10 px-3 py-1 font-semibold text-emerald-100 transition hover:border-emerald-300/50 disabled:cursor-not-allowed disabled:border-slate-800 disabled:bg-slate-900 disabled:text-slate-600"
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VariablesTable>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VariablesTable({ rows, loadingRows = 0, children }: { rows: VariableExplorerRow[]; loadingRows?: number; children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="relative overflow-hidden rounded-[1.25rem] border border-[#252b33] bg-[#0b0f14]/95 shadow-2xl shadow-black/30">
|
<div className="relative overflow-hidden rounded-[1.25rem] border border-[#252b33] bg-[#0b0f14]/95 shadow-2xl shadow-black/30">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full border-separate border-spacing-0 text-xs">
|
<table className="min-w-full border-separate border-spacing-0 text-xs">
|
||||||
@@ -285,76 +248,50 @@ function VariablesTable({ rows, loadingRows = 0, children }: { rows: VariableExp
|
|||||||
<TableHead>TAG</TableHead>
|
<TableHead>TAG</TableHead>
|
||||||
<TableHead>VARIABLE</TableHead>
|
<TableHead>VARIABLE</TableHead>
|
||||||
<TableHead>POZO</TableHead>
|
<TableHead>POZO</TableHead>
|
||||||
<TableHead>FRECUENCIA</TableHead>
|
<TableHead>FREC.</TableHead>
|
||||||
<TableHead>VALOR</TableHead>
|
<TableHead>VALOR</TableHead>
|
||||||
|
<TableHead>TENDENCIA</TableHead>
|
||||||
<TableHead>CALIDAD</TableHead>
|
<TableHead>CALIDAD</TableHead>
|
||||||
<TableHead>ESTADO</TableHead>
|
<TableHead>ESTADO</TableHead>
|
||||||
<TableHead>ACTUALIZADO</TableHead>
|
<TableHead>ACTUALIZADO</TableHead>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loadingRows > 0
|
{filteredRows.map((row) => {
|
||||||
? Array.from({ length: loadingRows }, (_, index) => <SkeletonRow key={`skeleton-${index}`} />)
|
const trendPoints = buildTrendSeries(currentSnapshot?.historyByAssetId[row.assetId] ?? null, row.tag).slice(-16)
|
||||||
: rows.map((row) => (
|
return (
|
||||||
<tr key={row.id} className="border-b border-[#252b33]/80 transition hover:bg-white/[0.025]">
|
<tr key={row.id} className="border-b border-[#252b33]/80 transition hover:bg-white/[0.025]">
|
||||||
<TableCell><span className="font-mono text-[11px] font-semibold text-cyan-200">{row.tag}</span></TableCell>
|
<TableCell><span className="font-mono text-[11px] font-semibold text-cyan-200">{row.tag}</span></TableCell>
|
||||||
<TableCell><div className="font-semibold text-white">{row.variable}</div><div className="text-[11px] text-slate-500">{row.deviceName}</div></TableCell>
|
<TableCell><div className="font-semibold text-white">{row.variable}</div><div className="text-[11px] text-slate-500">{row.deviceName}</div></TableCell>
|
||||||
<TableCell>{row.well}</TableCell>
|
<TableCell>{row.well}</TableCell>
|
||||||
<TableCell><FrequencyBadge row={row} /></TableCell>
|
<TableCell><span className="rounded border border-slate-800 bg-slate-950 px-2 py-1 font-mono text-[11px] text-slate-300">{row.frequency}</span></TableCell>
|
||||||
<TableCell><span className={`font-mono text-[13px] font-bold tabular-nums ${valueClassName[row.status]}`}>{row.value}</span></TableCell>
|
<TableCell><span className={`font-mono text-[13px] font-bold tabular-nums ${valueClassName[row.status]}`}>{row.value}</span></TableCell>
|
||||||
|
<TableCell><MiniTrend points={trendPoints} color={trendColor[row.status]} /></TableCell>
|
||||||
<TableCell><QualityBadge quality={row.quality} /></TableCell>
|
<TableCell><QualityBadge quality={row.quality} /></TableCell>
|
||||||
<TableCell><span className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-semibold ${statusClassName[row.status]}`}>{row.statusLabel}</span></TableCell>
|
<TableCell><span className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-semibold ${statusClassName[row.status]}`}>{row.statusLabel}</span></TableCell>
|
||||||
<TableCell><span className="font-mono text-[11px] text-slate-400">{row.updatedAtLabel}</span></TableCell>
|
<TableCell><span className="font-mono text-[11px] text-slate-400">{row.updatedAtLabel}</span></TableCell>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonRow() {
|
function FilterSelect({ label, value, onChange, options, labels = {} }: { label: string; value: string; onChange: (value: string) => void; options: string[]; labels?: Record<string, string> }) {
|
||||||
return (
|
|
||||||
<tr aria-label="Cargando variable real" className="animate-pulse border-b border-[#252b33]/80">
|
|
||||||
{Array.from({ length: 8 }, (_, index) => (
|
|
||||||
<TableCell key={index}>
|
|
||||||
<span className="block h-4 w-20 rounded bg-slate-800/80" />
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FrequencyBadge({ row }: { row: VariableExplorerRow }) {
|
|
||||||
const className = row.frequencyClass === 'high'
|
|
||||||
? 'border-orange-300/35 bg-orange-400/10 text-orange-100'
|
|
||||||
: row.frequencyClass === 'low'
|
|
||||||
? 'border-emerald-300/35 bg-emerald-400/10 text-emerald-100'
|
|
||||||
: 'border-slate-800 bg-slate-950 text-slate-400'
|
|
||||||
const label = row.frequencyClass === 'high' ? 'ALTA' : row.frequencyClass === 'low' ? 'BAJA' : 'SIN CLASE'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1 rounded border px-2 py-1 font-mono text-[11px] font-semibold ${className}`}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="text-slate-500">·</span>
|
|
||||||
<span className="text-slate-300">{row.frequency}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSelect({ label, value, onChange, options, labels = {}, allLabel = 'Todos', disabled = false }: { label: string; value: string; onChange: (value: string) => void; options: string[]; labels?: Record<string, string>; allLabel?: string; disabled?: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-1 text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500">
|
<label className="flex flex-col gap-1 text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500">
|
||||||
{label}
|
{label}
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
disabled={disabled}
|
className="h-9 rounded-lg border border-[#2a3038] bg-[#0b0f14] px-3 py-2 text-sm normal-case tracking-normal text-white outline-none transition focus:border-cyan-300"
|
||||||
className="h-9 rounded-lg border border-[#2a3038] bg-[#0b0f14] px-3 py-2 text-sm normal-case tracking-normal text-white outline-none transition focus:border-cyan-300 disabled:cursor-not-allowed disabled:text-slate-600"
|
|
||||||
>
|
>
|
||||||
<option value="all">{labels.all ?? allLabel}</option>
|
<option value="all">Todos</option>
|
||||||
{options.map((option) => <option key={option} value={option}>{labels[option] ?? option}</option>)}
|
{options.map((option) => <option key={option} value={option}>{labels[option] ?? option}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -369,6 +306,34 @@ function TableCell({ children }: { children: ReactNode }) {
|
|||||||
return <td className="whitespace-nowrap border-b border-slate-800/70 px-3 py-2 text-slate-300">{children}</td>
|
return <td className="whitespace-nowrap border-b border-slate-800/70 px-3 py-2 text-slate-300">{children}</td>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MiniTrend({ points, color }: { points: TrendSeriesPoint[]; color: string }) {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return <span className="text-[11px] text-slate-600">Sin datos</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 96
|
||||||
|
const height = 28
|
||||||
|
const values = points.map((point) => point.value)
|
||||||
|
const min = Math.min(...values)
|
||||||
|
const max = Math.max(...values)
|
||||||
|
const range = max - min || 1
|
||||||
|
const polyline = points.map((point, index) => {
|
||||||
|
const x = points.length === 1 ? width : (index / (points.length - 1)) * width
|
||||||
|
const y = height - ((point.value - min) / range) * (height - 4) - 2
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
const delta = points[points.length - 1].value - points[0].value
|
||||||
|
const deltaLabel = delta > 0 ? 'al alza' : delta < 0 ? 'a la baja' : 'estable'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Tendencia real: ${deltaLabel}`} className="overflow-visible">
|
||||||
|
<polyline points={polyline} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function QualityBadge({ quality }: { quality: string }) {
|
function QualityBadge({ quality }: { quality: string }) {
|
||||||
const normalized = quality.toLowerCase()
|
const normalized = quality.toLowerCase()
|
||||||
const className = normalized === 'good' || normalized === 'ok'
|
const className = normalized === 'good' || normalized === 'ok'
|
||||||
|
|||||||
@@ -41,14 +41,10 @@ async function waitForText(container: HTMLElement, text: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForSectionText(container: HTMLElement, selector: string, text: string) {
|
function setInputValue(input: HTMLInputElement, value: string) {
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||||
const section = container.querySelector(selector)
|
setter?.call(input, value)
|
||||||
if (section?.textContent?.includes(text)) return
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
await act(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WellDashboardPage', () => {
|
describe('WellDashboardPage', () => {
|
||||||
@@ -97,12 +93,12 @@ describe('WellDashboardPage', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
||||||
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { values: { temperatura: 42, presion: 120, nivel_tanque: 72 } } }],
|
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { values: { presion: 120, nivel_tanque: 72 } } }],
|
||||||
})
|
})
|
||||||
vi.mocked(fetchAssetTelemetry).mockResolvedValue([])
|
vi.mocked(fetchAssetTelemetry).mockResolvedValue([])
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
||||||
{ asset_id: 'well-1', time: '2026-06-22T09:55:00Z', data: { values: { presion: 118, temperatura: 41, nivel_tanque: 71 } } },
|
{ asset_id: 'well-1', time: '2026-06-22T09:55:00Z', data: { values: { presion: 118, nivel_tanque: 71 } } },
|
||||||
{ asset_id: 'well-1', time: '2026-06-22T10:00:00Z', data: { values: { presion: 120, temperatura: 42, nivel_tanque: 72 } } },
|
{ asset_id: 'well-1', time: '2026-06-22T10:00:00Z', data: { values: { presion: 120, nivel_tanque: 72 } } },
|
||||||
] })
|
] })
|
||||||
vi.mocked(getAlarms).mockResolvedValue([
|
vi.mocked(getAlarms).mockResolvedValue([
|
||||||
{ id: 'alarm-1', project_id: 'project-1', asset_id: 'well-1', variable_alias: 'presion', alarm_type: 'ABOVE_MAX', message: 'Presión alta', acknowledged: false, timestamp: '2026-06-22T10:01:00Z' },
|
{ id: 'alarm-1', project_id: 'project-1', asset_id: 'well-1', variable_alias: 'presion', alarm_type: 'ABOVE_MAX', message: 'Presión alta', acknowledged: false, timestamp: '2026-06-22T10:01:00Z' },
|
||||||
@@ -138,7 +134,7 @@ describe('WellDashboardPage', () => {
|
|||||||
|
|
||||||
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
|
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
|
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
|
||||||
expect(fetchAssetTelemetry).toHaveBeenCalledWith('well-1', 100)
|
expect(fetchAssetTelemetry).not.toHaveBeenCalled()
|
||||||
expect(getAlarms).toHaveBeenCalledWith('project-1')
|
expect(getAlarms).toHaveBeenCalledWith('project-1')
|
||||||
expect(container.textContent).toContain('Pozo Norte')
|
expect(container.textContent).toContain('Pozo Norte')
|
||||||
expect(container.textContent).toContain('Activo')
|
expect(container.textContent).toContain('Activo')
|
||||||
@@ -147,7 +143,7 @@ describe('WellDashboardPage', () => {
|
|||||||
expect(container.textContent).toContain('Presión alta')
|
expect(container.textContent).toContain('Presión alta')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes critical telemetry gauges and min/max apply controls', async () => {
|
it('renders selected variable chart and latest well alerts below gauges', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -162,15 +158,12 @@ describe('WellDashboardPage', () => {
|
|||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, 'Presión alta')
|
await waitForText(container, 'Presión alta')
|
||||||
|
|
||||||
|
const gaugeSection = container.querySelector('section[aria-label="Gauges de variables críticas"]')
|
||||||
const chartAlertsSection = container.querySelector('section[aria-label="Tendencia seleccionada y últimas alertas"]')
|
const chartAlertsSection = container.querySelector('section[aria-label="Tendencia seleccionada y últimas alertas"]')
|
||||||
|
|
||||||
|
expect(gaugeSection).not.toBeNull()
|
||||||
expect(chartAlertsSection).not.toBeNull()
|
expect(chartAlertsSection).not.toBeNull()
|
||||||
expect(container.querySelector('section[aria-label="Gauges de variables críticas"]')).toBeNull()
|
expect(gaugeSection?.compareDocumentPosition(chartAlertsSection as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||||
expect(container.textContent).not.toContain('Telemetría crítica')
|
|
||||||
expect(container.textContent).not.toContain('Variables principales')
|
|
||||||
expect(container.textContent).not.toContain('Aplicar')
|
|
||||||
expect(container.querySelector('input[aria-label^="Mínimo"]')).toBeNull()
|
|
||||||
expect(container.querySelector('input[aria-label^="Máximo"]')).toBeNull()
|
|
||||||
expect(chartAlertsSection?.textContent).toContain('Variable seleccionada')
|
expect(chartAlertsSection?.textContent).toContain('Variable seleccionada')
|
||||||
expect(chartAlertsSection?.textContent).toContain('Último valor')
|
expect(chartAlertsSection?.textContent).toContain('Último valor')
|
||||||
expect(chartAlertsSection?.textContent).toContain('Últimas alertas del pozo')
|
expect(chartAlertsSection?.textContent).toContain('Últimas alertas del pozo')
|
||||||
@@ -178,40 +171,7 @@ describe('WellDashboardPage', () => {
|
|||||||
expect(chartAlertsSection?.textContent).toContain('Alerta')
|
expect(chartAlertsSection?.textContent).toContain('Alerta')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps high-frequency grid order stable from inventory while live telemetry values change', async () => {
|
it('renders gauge cards with real selected variables and configured ranges', async () => {
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
|
||||||
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
asset: {
|
|
||||||
id: 'well-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
code: 'PZ-01',
|
|
||||||
name: 'Pozo Norte',
|
|
||||||
asset_type: 'POZO',
|
|
||||||
is_active: true,
|
|
||||||
metadata: {},
|
|
||||||
is_collector_enabled: true,
|
|
||||||
created_at: '2026-06-22T00:00:00Z',
|
|
||||||
updated_at: '2026-06-22T00:00:00Z',
|
|
||||||
},
|
|
||||||
devices: [
|
|
||||||
{
|
|
||||||
device: { id: 'device-1', asset_id: 'well-1', name: 'PLC Pozo', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
variables: [
|
|
||||||
{ id: 'var-b', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'beta', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Beta', sort_order: 2 }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-a', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'alpha', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Alpha', sort_order: 1 }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'var-c', device_id: 'device-1', address: '40003', data_type: 'float32', alias: 'gamma', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Gamma', sort_order: 3 }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { values: { gamma: 30, beta: 20, alpha: 10 } } }],
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -224,31 +184,18 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '3 señales reales')
|
await waitForText(container, 'presion')
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]') as HTMLElement
|
expect(container.textContent).toContain('Variables principales')
|
||||||
const cardsBefore = Array.from(highFrequencySection.querySelectorAll<HTMLButtonElement>('button[data-variable-key]'))
|
expect(container.textContent).toContain('Telemetría crítica')
|
||||||
const orderBefore = cardsBefore.map((card) => card.dataset.variableKey)
|
expect(container.textContent).toContain('presion')
|
||||||
|
expect(container.textContent).toContain('120')
|
||||||
expect(orderBefore).toEqual(['var-a', 'var-b', 'var-c'])
|
expect(container.textContent).toContain('PSI')
|
||||||
expect(highFrequencySection.textContent).toMatch(/Alpha[\s\S]*10[\s\S]*Beta[\s\S]*20[\s\S]*Gamma[\s\S]*30/)
|
expect(container.textContent).toContain('rango 80-150 PSI')
|
||||||
expect(cardsBefore[0].getAttribute('aria-label')).toContain('Abrir detalle de Alpha')
|
expect(container.textContent).not.toContain('185')
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
queryClient.setQueryData(['well-dashboard-summary'], {
|
|
||||||
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:05Z', telemetry: { values: { beta: 200, gamma: 300, alpha: 100 } } }],
|
|
||||||
})
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
await waitForText(container, '100')
|
|
||||||
|
|
||||||
const cardsAfter = Array.from(highFrequencySection.querySelectorAll<HTMLButtonElement>('button[data-variable-key]'))
|
|
||||||
expect(cardsAfter.map((card) => card.dataset.variableKey)).toEqual(orderBefore)
|
|
||||||
expect(highFrequencySection.textContent).toMatch(/Alpha[\s\S]*100[\s\S]*Beta[\s\S]*200[\s\S]*Gamma[\s\S]*300/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens a variable details modal with real values and historical chart data', async () => {
|
it('persists edited gauge ranges in localStorage', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -261,40 +208,23 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '2 señales reales')
|
await waitForText(container, 'presion')
|
||||||
|
|
||||||
const pressureCard = container.querySelector('button[data-variable-key="var-1"]') as HTMLButtonElement
|
const minInput = container.querySelector('input[aria-label="Mínimo presion"]') as HTMLInputElement
|
||||||
await act(async () => {
|
const maxInput = container.querySelector('input[aria-label="Máximo presion"]') as HTMLInputElement
|
||||||
pressureCard.click()
|
const applyButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Aplicar') as HTMLButtonElement
|
||||||
})
|
|
||||||
|
|
||||||
const modal = container.querySelector('[role="dialog"]')
|
|
||||||
expect(modal).not.toBeNull()
|
|
||||||
expect(modal?.textContent).toContain('Detalle de variable')
|
|
||||||
expect(modal?.textContent).toContain('Tag: presion')
|
|
||||||
expect(modal?.textContent).toContain('Pozo: Pozo Norte')
|
|
||||||
expect(modal?.textContent).toContain('120')
|
|
||||||
expect(modal?.textContent).toContain('PSI')
|
|
||||||
expect(modal?.textContent).toContain('22/6')
|
|
||||||
expect(modal?.textContent).toContain('Alarma activa')
|
|
||||||
expect(modal?.textContent).toContain('Cada 5 s')
|
|
||||||
expect(modal?.textContent).toContain('80-150 PSI')
|
|
||||||
expect(modal?.textContent).toContain('Promedio histórico')
|
|
||||||
expect(modal?.textContent).toContain('119 PSI')
|
|
||||||
expect(modal?.querySelector('.recharts-responsive-container')).not.toBeNull()
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
container.querySelector<HTMLButtonElement>('button[aria-label="Cerrar detalle de variable"]')?.click()
|
setInputValue(minInput, '90')
|
||||||
|
setInputValue(maxInput, '140')
|
||||||
|
applyButton.click()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
expect(container.textContent).toContain('rango 90-140 PSI')
|
||||||
|
expect(window.localStorage.getItem('well-dashboard:gauge-range:well-1:var-1')).toBe('{"min":90,"max":140}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the modal empty history state when real history has no samples for the variable', async () => {
|
it('validates editable gauge ranges before applying them', async () => {
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
|
||||||
{ asset_id: 'well-1', time: '2026-06-22T10:00:00Z', data: { values: { presion: 120, nivel_tanque: 72 } } },
|
|
||||||
] })
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -307,21 +237,22 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '2 señales reales')
|
await waitForText(container, 'presion')
|
||||||
|
|
||||||
|
const minInput = container.querySelector('input[aria-label="Mínimo presion"]') as HTMLInputElement
|
||||||
|
const maxInput = container.querySelector('input[aria-label="Máximo presion"]') as HTMLInputElement
|
||||||
|
|
||||||
const temperatureCard = container.querySelector('button[data-variable-key="var-2"]') as HTMLButtonElement
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
temperatureCard.click()
|
setInputValue(minInput, '150')
|
||||||
|
setInputValue(maxInput, '120')
|
||||||
})
|
})
|
||||||
|
|
||||||
const modal = container.querySelector('[role="dialog"]')
|
const applyButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Aplicar') as HTMLButtonElement
|
||||||
expect(modal?.textContent).toContain('Temperatura motor')
|
expect(container.textContent).toContain('El mínimo debe ser menor que el máximo.')
|
||||||
expect(modal?.textContent).toContain('42')
|
expect(applyButton.disabled).toBe(true)
|
||||||
expect(modal?.textContent).toContain('Sin histórico disponible para esta variable.')
|
|
||||||
expect(modal?.querySelector('.recharts-responsive-container')).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders enabled high-frequency inventory cards even when live telemetry is missing', async () => {
|
it('renders only active high-frequency variables and omits no-data cards', async () => {
|
||||||
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
||||||
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
assets: [
|
assets: [
|
||||||
@@ -371,21 +302,22 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '2 señales reales')
|
await waitForText(container, '1 señales reales')
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
||||||
|
|
||||||
expect(container.textContent).toContain('Variables de alta frecuencia')
|
expect(container.textContent).toContain('Variables de alta frecuencia')
|
||||||
expect(highFrequencySection?.textContent).toContain('2 señales reales')
|
expect(highFrequencySection?.textContent).toContain('1 señales reales')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('120 señales')
|
expect(highFrequencySection?.textContent).not.toContain('120 señales')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('100 señales')
|
expect(highFrequencySection?.textContent).not.toContain('100 señales')
|
||||||
expect(highFrequencySection?.textContent).toContain('presion')
|
expect(highFrequencySection?.textContent).toContain('presion')
|
||||||
expect(highFrequencySection?.textContent).toContain('120')
|
expect(highFrequencySection?.textContent).toContain('120')
|
||||||
expect(highFrequencySection?.textContent).toContain('Temperatura motor')
|
expect(highFrequencySection?.textContent).not.toContain('Temperatura motor')
|
||||||
expect(highFrequencySection?.textContent).toContain('Sin dato')
|
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Temperatura alta')
|
expect(highFrequencySection?.textContent).not.toContain('Temperatura alta')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Variable deshabilitada')
|
expect(highFrequencySection?.textContent).not.toContain('Variable deshabilitada')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('999')
|
expect(highFrequencySection?.textContent).not.toContain('999')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders large active high-frequency lists progressively', async () => {
|
it('renders large active high-frequency lists progressively', async () => {
|
||||||
@@ -473,7 +405,6 @@ describe('WellDashboardPage', () => {
|
|||||||
expect(highFrequencySection?.textContent).toContain('Alta 24')
|
expect(highFrequencySection?.textContent).toContain('Alta 24')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Alta 25')
|
expect(highFrequencySection?.textContent).not.toContain('Alta 25')
|
||||||
expect(highFrequencySection?.textContent).toContain('Cargando 31 señales activas más')
|
expect(highFrequencySection?.textContent).toContain('Cargando 31 señales activas más')
|
||||||
expect(highFrequencySection?.querySelectorAll('[aria-hidden="true"]')).toHaveLength(31)
|
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
||||||
|
|
||||||
@@ -484,7 +415,6 @@ describe('WellDashboardPage', () => {
|
|||||||
expect(highFrequencySection?.textContent).toContain('Alta 48')
|
expect(highFrequencySection?.textContent).toContain('Alta 48')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Alta 49')
|
expect(highFrequencySection?.textContent).not.toContain('Alta 49')
|
||||||
expect(highFrequencySection?.textContent).toContain('Cargando 7 señales activas más')
|
expect(highFrequencySection?.textContent).toContain('Cargando 7 señales activas más')
|
||||||
expect(highFrequencySection?.querySelectorAll('[aria-hidden="true"]')).toHaveLength(7)
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(16)
|
vi.advanceTimersByTime(16)
|
||||||
@@ -492,7 +422,6 @@ describe('WellDashboardPage', () => {
|
|||||||
|
|
||||||
expect(highFrequencySection?.textContent).toContain('Alta 55')
|
expect(highFrequencySection?.textContent).toContain('Alta 55')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Cargando')
|
expect(highFrequencySection?.textContent).not.toContain('Cargando')
|
||||||
expect(highFrequencySection?.querySelectorAll('[aria-hidden="true"]')).toHaveLength(0)
|
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
||||||
|
|
||||||
@@ -521,287 +450,18 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '2 señales reales')
|
await waitForText(container, '1 señales reales')
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
||||||
|
|
||||||
expect(highFrequencySection?.textContent).toContain('2 señales reales')
|
expect(highFrequencySection?.textContent).toContain('1 señales reales')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Nivel tanque')
|
expect(highFrequencySection?.textContent).not.toContain('Nivel tanque')
|
||||||
expect(lowFrequencySection?.textContent).toContain('Nivel tanque')
|
expect(lowFrequencySection?.textContent).toContain('Nivel tanque')
|
||||||
expect(lowFrequencySection?.textContent).toContain('72')
|
expect(lowFrequencySection?.textContent).toContain('72')
|
||||||
expect(lowFrequencySection?.textContent).toContain('Esperada: Cada 1 min')
|
expect(lowFrequencySection?.textContent).toContain('Esperada: Cada 1 min')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a stable single historical group with unit filters', async () => {
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
|
||||||
{ asset_id: 'well-1', time: '2026-06-22T09:55:00Z', data: { values: { presion: 118, temperatura: 41, nivel_tanque: 71 } } },
|
|
||||||
{ asset_id: 'well-1', time: '2026-06-22T10:00:00Z', data: { values: { presion: 120, temperatura: 42, nivel_tanque: 72 } } },
|
|
||||||
] })
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
await waitForSectionText(container, 'section[aria-label="Histórico del pozo"]', 'PSI · 1 vars')
|
|
||||||
|
|
||||||
const historySection = container.querySelector('section[aria-label="Histórico del pozo"]')
|
|
||||||
const chartPanels = historySection?.querySelectorAll('[aria-label$="telemetry panel with 1 variable"]')
|
|
||||||
const temperatureFilter = Array.from(historySection?.querySelectorAll('button') ?? []).find((button) => button.textContent?.includes('°C')) as HTMLButtonElement
|
|
||||||
|
|
||||||
expect(historySection?.textContent).toContain('Filtrá por grupo')
|
|
||||||
expect(historySection?.textContent).toContain('PSI · 1 vars')
|
|
||||||
expect(historySection?.textContent).toContain('°C · 1 vars')
|
|
||||||
expect(historySection?.textContent).toContain('pies · 1 vars')
|
|
||||||
expect(chartPanels).toHaveLength(1)
|
|
||||||
expect(historySection?.querySelector('[aria-label^="PSI telemetry panel"]')).not.toBeNull()
|
|
||||||
expect(historySection?.querySelector('[aria-label^="°C telemetry panel"]')).toBeNull()
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
temperatureFilter.click()
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(temperatureFilter.getAttribute('aria-pressed')).toBe('true')
|
|
||||||
expect(historySection?.querySelector('[aria-label^="°C telemetry panel"]')).not.toBeNull()
|
|
||||||
expect(historySection?.querySelectorAll('[aria-label$="telemetry panel with 1 variable"]')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes LF capture fields only in the low-frequency section', async () => {
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'PWA',
|
|
||||||
frequency_class: 'LF',
|
|
||||||
last_updated: '2026-06-22T10:00:00Z',
|
|
||||||
telemetry: { values: { presion: 120, nivel_tanque: 72, caudal_bopd: 84 }, source: 'PWA', frequency_class: 'LF' },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
await waitForText(container, 'Caudal bopd')
|
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
|
||||||
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Caudal bopd')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Captura PWA')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('84')
|
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Caudal bopd')
|
|
||||||
expect(container.querySelector('section[aria-label="Gauges de variables críticas"]')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes LF capture fields from the selected well history when the latest summary is HF', async () => {
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'SCADA',
|
|
||||||
frequency_class: 'HF',
|
|
||||||
last_updated: '2026-06-22T10:05:00Z',
|
|
||||||
telemetry: { values: { presion: 121 } },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
|
||||||
{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
time: '2026-06-22T10:00:00Z',
|
|
||||||
source: 'PWA',
|
|
||||||
frequency_class: 'LF',
|
|
||||||
data: { values: { presion: 120, caudal_bopd: 84, bsw_pct: 2.7 } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
asset_id: 'well-2',
|
|
||||||
time: '2026-06-22T10:00:00Z',
|
|
||||||
source: 'PWA',
|
|
||||||
frequency_class: 'LF',
|
|
||||||
data: { values: { otro_dato: 999 } },
|
|
||||||
},
|
|
||||||
] })
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
await waitForText(container, 'Caudal bopd')
|
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
|
||||||
|
|
||||||
expect(highFrequencySection?.textContent).toContain('presion')
|
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Caudal bopd')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Caudal bopd')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('84')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Bsw pct')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('2.7')
|
|
||||||
expect(lowFrequencySection?.textContent).not.toContain('Otro dato')
|
|
||||||
expect(lowFrequencySection?.textContent).not.toContain('No hay variables de baja frecuencia')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes LF capture fields from raw telemetry when history lacks origin metadata', async () => {
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'SCADA',
|
|
||||||
frequency_class: 'HF',
|
|
||||||
last_updated: '2026-06-22T10:05:00Z',
|
|
||||||
telemetry: { values: { presion: 121 } },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
|
||||||
{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
time: '2026-06-22T10:00:00Z',
|
|
||||||
data: { values: { caudal_bopd: 84 } },
|
|
||||||
},
|
|
||||||
] })
|
|
||||||
vi.mocked(fetchAssetTelemetry).mockResolvedValue([
|
|
||||||
{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
time: '2026-06-22T10:00:00Z',
|
|
||||||
source: 'PWA',
|
|
||||||
data: { values: { presion: 120, caudal_bopd: 84 } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
await waitForText(container, 'Caudal bopd')
|
|
||||||
|
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
|
||||||
|
|
||||||
expect(fetchAssetTelemetry).toHaveBeenCalledWith('well-1', 100)
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Caudal bopd')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('84')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Captura PWA')
|
|
||||||
expect(lowFrequencySection?.textContent).not.toContain('No hay variables de baja frecuencia')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the last LF capture cards visible while raw telemetry refreshes', async () => {
|
|
||||||
let resolveRawRefresh: (value: unknown[]) => void = () => {}
|
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
|
||||||
data: [{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
asset: 'Pozo Norte',
|
|
||||||
source: 'SCADA',
|
|
||||||
frequency_class: 'HF',
|
|
||||||
last_updated: '2026-06-22T10:05:00Z',
|
|
||||||
telemetry: { values: { presion: 121 } },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [] })
|
|
||||||
vi.mocked(fetchAssetTelemetry)
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
asset_id: 'well-1',
|
|
||||||
time: '2026-06-22T10:00:00Z',
|
|
||||||
source: 'PWA',
|
|
||||||
data: { values: { caudal_bopd: 84 } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
await waitForText(container, 'Caudal bopd')
|
|
||||||
|
|
||||||
vi.mocked(fetchAssetTelemetry).mockReturnValueOnce(new Promise((resolve) => {
|
|
||||||
resolveRawRefresh = resolve
|
|
||||||
}) as ReturnType<typeof fetchAssetTelemetry>)
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['well-dashboard-raw-telemetry', 'well-1'] })
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
|
||||||
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Caudal bopd')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('84')
|
|
||||||
expect(lowFrequencySection?.textContent).not.toContain('Cargando variables de baja frecuencia')
|
|
||||||
expect(lowFrequencySection?.textContent).not.toContain('No hay variables de baja frecuencia')
|
|
||||||
expect(lowFrequencySection?.className).toContain('min-h-[320px]')
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveRawRefresh([])
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows loading copy before rendering empty high and low frequency states', async () => {
|
|
||||||
vi.mocked(getProjectFullConfig).mockReturnValue(new Promise(() => undefined) as ReturnType<typeof getProjectFullConfig>)
|
|
||||||
vi.mocked(fetchDashboardSummary).mockReturnValue(new Promise(() => undefined) as ReturnType<typeof fetchDashboardSummary>)
|
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockReturnValue(new Promise(() => undefined) as ReturnType<typeof fetchAssetTelemetryHistory>)
|
|
||||||
vi.mocked(fetchAssetTelemetry).mockReturnValue(new Promise(() => undefined) as ReturnType<typeof fetchAssetTelemetry>)
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
|
||||||
|
|
||||||
expect(highFrequencySection?.textContent).toContain('Cargando variables de alta frecuencia')
|
|
||||||
expect(lowFrequencySection?.textContent).toContain('Cargando variables de baja frecuencia')
|
|
||||||
expect(lowFrequencySection?.textContent).not.toContain('No hay variables de baja frecuencia')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows honest empty states when the well has no telemetry or alarms', async () => {
|
it('shows honest empty states when the well has no telemetry or alarms', async () => {
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({ data: [] })
|
vi.mocked(fetchDashboardSummary).mockResolvedValue({ data: [] })
|
||||||
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [] })
|
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [] })
|
||||||
@@ -820,18 +480,17 @@ describe('WellDashboardPage', () => {
|
|||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, 'Sin alertas activas')
|
await waitForText(container, 'Sin alertas activas')
|
||||||
await waitForSectionText(container, 'section[aria-label="Variables de alta frecuencia"]', '2 señales reales')
|
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
||||||
|
|
||||||
expect(container.textContent).toContain('Sin dato')
|
expect(container.textContent).toContain('Sin dato')
|
||||||
expect(container.textContent).toContain('Sin alertas activas')
|
expect(container.textContent).toContain('Sin alertas activas')
|
||||||
expect(container.textContent).toContain('Sin historial')
|
expect(container.textContent).toContain('Sin historial')
|
||||||
expect(highFrequencySection?.textContent).toContain('2 señales reales')
|
expect(highFrequencySection?.textContent).toContain('0 señales reales')
|
||||||
expect(highFrequencySection?.textContent).toContain('presion')
|
expect(highFrequencySection?.textContent).toContain('Sin variables configuradas')
|
||||||
expect(highFrequencySection?.textContent).toContain('Temperatura motor')
|
expect(highFrequencySection?.textContent).not.toContain('presion')
|
||||||
expect(highFrequencySection?.textContent).toContain('Sin dato')
|
expect(highFrequencySection?.textContent).not.toContain('Temperatura motor')
|
||||||
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
|
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
|
||||||
expect(fetchAssetTelemetry).toHaveBeenCalledWith('well-1', 100)
|
expect(fetchAssetTelemetry).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Activity, ArrowLeft, Database, Gauge } from 'lucide-react'
|
import { Activity, ArrowLeft, Database, Gauge } from 'lucide-react'
|
||||||
@@ -6,11 +6,11 @@ import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YA
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
||||||
import { fetchAssetTelemetry, fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryRecord } from '../api/dashboard-api'
|
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryRecord } from '../api/dashboard-api'
|
||||||
import { DashboardEmptyState } from '../components/DashboardEmptyState'
|
import { DashboardEmptyState } from '../components/DashboardEmptyState'
|
||||||
import { TelemetryChart } from '../components/TelemetryChart'
|
import { TelemetryChart } from '../components/TelemetryChart'
|
||||||
import type { AssetData } from '../utils/telemetry-normalization'
|
import type { AssetData } from '../utils/telemetry-normalization'
|
||||||
import { deriveCaptureFields, normalizeTelemetry } from '../utils/telemetry-normalization'
|
import { normalizeTelemetry } from '../utils/telemetry-normalization'
|
||||||
import {
|
import {
|
||||||
getUnitBackgroundClass,
|
getUnitBackgroundClass,
|
||||||
getUnitColorClass,
|
getUnitColorClass,
|
||||||
@@ -20,30 +20,21 @@ import { getProjectFullConfig } from '../../projects/api/projects-api'
|
|||||||
import type { EdgeVariable } from '../../projects/api/variables-api'
|
import type { EdgeVariable } from '../../projects/api/variables-api'
|
||||||
|
|
||||||
type VariableSignalStatus = 'alarm' | 'warning' | 'normal' | 'no-data'
|
type VariableSignalStatus = 'alarm' | 'warning' | 'normal' | 'no-data'
|
||||||
|
type GaugeRange = { min: number; max: number; source: 'metadata' | 'history' | 'value' }
|
||||||
type FrequencyClass = 'high' | 'low' | 'unknown'
|
type FrequencyClass = 'high' | 'low' | 'unknown'
|
||||||
|
|
||||||
|
const GAUGE_RANGE_STORAGE_PREFIX = 'well-dashboard:gauge-range'
|
||||||
|
const gaugeArcPath = 'M 20 88 A 68 68 0 0 1 156 88'
|
||||||
const HIGH_FREQUENCY_BATCH_SIZE = 24
|
const HIGH_FREQUENCY_BATCH_SIZE = 24
|
||||||
const HIGH_FREQUENCY_BATCH_DELAY_MS = 16
|
const HIGH_FREQUENCY_BATCH_DELAY_MS = 16
|
||||||
const HISTORY_UNIT_PRIORITY = ['PSI', '°C', 'BPD', 'BBL', '%', 'pies', 'RAW']
|
|
||||||
|
|
||||||
function getHistoryUnitRank(unit: string) {
|
function normalizeHistoryRecord(record: TelemetryHistoryRecord): { time: string; asset_id?: string; data?: Record<string, unknown> } {
|
||||||
const index = HISTORY_UNIT_PRIORITY.indexOf(unit)
|
|
||||||
return index === -1 ? HISTORY_UNIT_PRIORITY.length : index
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortHistoryGroups<T extends { unit: string }>(groups: T[]) {
|
|
||||||
return [...groups].sort((a, b) => getHistoryUnitRank(a.unit) - getHistoryUnitRank(b.unit) || a.unit.localeCompare(b.unit))
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHistoryRecord(record: TelemetryHistoryRecord): { time: string; asset_id?: string; source?: AssetData['source']; frequency_class?: AssetData['frequency_class']; data?: Record<string, unknown> } {
|
|
||||||
const data = record.data || record.values || record.telemetry || record
|
const data = record.data || record.values || record.telemetry || record
|
||||||
const time = record.time || record.created_at || record.timestamp || new Date().toISOString()
|
const time = record.time || record.created_at || record.timestamp || new Date().toISOString()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
asset_id: record.asset_id,
|
asset_id: record.asset_id,
|
||||||
source: record.source,
|
|
||||||
frequency_class: record.frequency_class,
|
|
||||||
data,
|
data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,43 +91,6 @@ function formatRangeValue(value: number) {
|
|||||||
return Number.isInteger(value) ? String(value) : Number(value.toFixed(1)).toString()
|
return Number.isInteger(value) ? String(value) : Number(value.toFixed(1)).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariableKey(variable: EdgeVariable) {
|
|
||||||
return variable.id || variable.alias
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSortOrder(variable: EdgeVariable) {
|
|
||||||
const metadata = variable.metadata
|
|
||||||
return getNumericMetadata(metadata, ['sort_order', 'sortOrder', 'order', 'orden', 'display_order', 'displayOrder'])
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortVariableSignals<T extends { variable: EdgeVariable; label: string }>(signals: T[]) {
|
|
||||||
return [...signals].sort((a, b) => {
|
|
||||||
const orderA = getSortOrder(a.variable)
|
|
||||||
const orderB = getSortOrder(b.variable)
|
|
||||||
if (orderA !== null || orderB !== null) {
|
|
||||||
const orderCompare = (orderA ?? Number.MAX_SAFE_INTEGER) - (orderB ?? Number.MAX_SAFE_INTEGER)
|
|
||||||
if (orderCompare !== 0) return orderCompare
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryCompare = getVariableCategory(a.variable).localeCompare(getVariableCategory(b.variable), 'es', { sensitivity: 'base' })
|
|
||||||
if (categoryCompare !== 0) return categoryCompare
|
|
||||||
|
|
||||||
const labelCompare = a.label.localeCompare(b.label, 'es', { sensitivity: 'base' })
|
|
||||||
if (labelCompare !== 0) return labelCompare
|
|
||||||
|
|
||||||
return a.variable.alias.localeCompare(b.variable.alias, 'es', { sensitivity: 'base' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfiguredRange(variable: EdgeVariable) {
|
|
||||||
const metadataMin = getNumericMetadata(variable.metadata, ['range_min', 'rangeMin', 'threshold_min', 'thresholdMin', 'min'])
|
|
||||||
const metadataMax = getNumericMetadata(variable.metadata, ['range_max', 'rangeMax', 'threshold_max', 'thresholdMax', 'max'])
|
|
||||||
const min = typeof variable.alarm_min === 'number' ? variable.alarm_min : metadataMin
|
|
||||||
const max = typeof variable.alarm_max === 'number' ? variable.alarm_max : metadataMax
|
|
||||||
|
|
||||||
return typeof min === 'number' && typeof max === 'number' && min < max ? { min, max } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVariableSparkline(variable: EdgeVariable, history: Array<{ data?: Record<string, unknown> }>) {
|
function getVariableSparkline(variable: EdgeVariable, history: Array<{ data?: Record<string, unknown> }>) {
|
||||||
return history
|
return history
|
||||||
.map((record) => getVariableValue(variable, record.data))
|
.map((record) => getVariableValue(variable, record.data))
|
||||||
@@ -144,17 +98,6 @@ function getVariableSparkline(variable: EdgeVariable, history: Array<{ data?: Re
|
|||||||
.reverse()
|
.reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariableHistoryPoints(variable: EdgeVariable, history: Array<{ time: string; data?: Record<string, unknown> }>) {
|
|
||||||
return [...history]
|
|
||||||
.reverse()
|
|
||||||
.map((record) => ({
|
|
||||||
timestamp: record.time,
|
|
||||||
time: formatDateTime(record.time),
|
|
||||||
value: getVariableValue(variable, record.data),
|
|
||||||
}))
|
|
||||||
.filter((point): point is { timestamp: string; time: string; value: number } => typeof point.value === 'number' && Number.isFinite(point.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPollingIntervalMs(variable: EdgeVariable) {
|
function getPollingIntervalMs(variable: EdgeVariable) {
|
||||||
const direct = typeof variable.polling_interval_ms === 'number' ? variable.polling_interval_ms : null
|
const direct = typeof variable.polling_interval_ms === 'number' ? variable.polling_interval_ms : null
|
||||||
return direct ?? getNumericMetadata(variable.metadata, ['polling_interval_ms', 'pollingIntervalMs', 'polling_interval', 'pollingInterval'])
|
return direct ?? getNumericMetadata(variable.metadata, ['polling_interval_ms', 'pollingIntervalMs', 'polling_interval', 'pollingInterval'])
|
||||||
@@ -194,6 +137,71 @@ function getAlarmSeverityLabel(alarm: { severity?: unknown; level?: unknown; pri
|
|||||||
return String(rawSeverity).toUpperCase()
|
return String(rawSeverity).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGaugeRangeStorageKey(wellId: string | undefined, variable: EdgeVariable) {
|
||||||
|
return `${GAUGE_RANGE_STORAGE_PREFIX}:${wellId ?? 'sin-pozo'}:${variable.id || variable.alias}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredGaugeRange(key: string): { min: number; max: number } | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(window.localStorage.getItem(key) ?? 'null') as { min?: unknown; max?: unknown } | null
|
||||||
|
if (!parsed) return null
|
||||||
|
const min = typeof parsed.min === 'number' ? parsed.min : Number(parsed.min)
|
||||||
|
const max = typeof parsed.max === 'number' ? parsed.max : Number(parsed.max)
|
||||||
|
return Number.isFinite(min) && Number.isFinite(max) && min < max ? { min, max } : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredGaugeRange(key: string, range: { min: number; max: number }) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(range))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultGaugeRange(variable: EdgeVariable, value: number | string | null, sparklineValues: number[]): GaugeRange {
|
||||||
|
const metadataMin = getNumericMetadata(variable.metadata, ['range_min', 'rangeMin', 'threshold_min', 'thresholdMin', 'min'])
|
||||||
|
const metadataMax = getNumericMetadata(variable.metadata, ['range_max', 'rangeMax', 'threshold_max', 'thresholdMax', 'max'])
|
||||||
|
const configuredMin = typeof variable.alarm_min === 'number' ? variable.alarm_min : metadataMin
|
||||||
|
const configuredMax = typeof variable.alarm_max === 'number' ? variable.alarm_max : metadataMax
|
||||||
|
|
||||||
|
if (typeof configuredMin === 'number' && typeof configuredMax === 'number' && configuredMin < configuredMax) {
|
||||||
|
return { min: configuredMin, max: configuredMax, source: 'metadata' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sparklineValues.length > 0) {
|
||||||
|
const historyMin = Math.min(...sparklineValues)
|
||||||
|
const historyMax = Math.max(...sparklineValues)
|
||||||
|
if (historyMin < historyMax) return { min: historyMin, max: historyMax, source: 'history' }
|
||||||
|
return { min: historyMin - 1, max: historyMax + 1, source: 'history' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||||
|
const padding = Math.max(Math.abs(numericValue) * 0.2, 1)
|
||||||
|
return { min: numericValue - padding, max: numericValue + padding, source: 'value' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGaugePriority(variable: EdgeVariable, label: string) {
|
||||||
|
const searchable = `${variable.alias} ${label} ${String(variable.metadata?.category ?? '')} ${String(variable.metadata?.categoria ?? '')}`.toLowerCase()
|
||||||
|
if (searchable.includes('pres') || searchable.includes('pressure')) return 0
|
||||||
|
if (searchable.includes('temp')) return 1
|
||||||
|
if (searchable.includes('caudal') || searchable.includes('flow')) return 2
|
||||||
|
if (searchable.includes('vib')) return 3
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGaugeStatus(value: number | string | null, range: { min: number; max: number }): VariableSignalStatus {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return 'no-data'
|
||||||
|
if (value < range.min || value > range.max) return 'warning'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGaugeFill(value: number | string | null, range: { min: number; max: number }) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
|
||||||
|
const percent = ((value - range.min) / (range.max - range.min)) * 100
|
||||||
|
return Math.min(100, Math.max(0, percent))
|
||||||
|
}
|
||||||
|
|
||||||
function buildSparklinePath(values: number[]) {
|
function buildSparklinePath(values: number[]) {
|
||||||
if (values.length < 2) return ''
|
if (values.length < 2) return ''
|
||||||
const min = Math.min(...values)
|
const min = Math.min(...values)
|
||||||
@@ -223,19 +231,12 @@ const variableStatusClasses: Record<VariableSignalStatus, string> = {
|
|||||||
'no-data': 'bg-[#64748b]',
|
'no-data': 'bg-[#64748b]',
|
||||||
}
|
}
|
||||||
|
|
||||||
const variableStatusLabels: Record<VariableSignalStatus, string> = {
|
|
||||||
alarm: 'Alarma activa',
|
|
||||||
warning: 'Advertencia',
|
|
||||||
normal: 'Normal',
|
|
||||||
'no-data': 'Sin dato',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WellDashboardPage() {
|
export default function WellDashboardPage() {
|
||||||
const { wellId } = useParams()
|
const { wellId } = useParams()
|
||||||
const [visibleVars, setVisibleVars] = useState<Record<string, boolean>>({})
|
const [visibleVars, setVisibleVars] = useState<Record<string, boolean>>({})
|
||||||
|
const [storedGaugeRanges, setStoredGaugeRanges] = useState<Record<string, { min: number; max: number }>>({})
|
||||||
|
const [draftGaugeRanges, setDraftGaugeRanges] = useState<Record<string, { min: string; max: string }>>({})
|
||||||
const [selectedVariableId, setSelectedVariableId] = useState<string | null>(null)
|
const [selectedVariableId, setSelectedVariableId] = useState<string | null>(null)
|
||||||
const [modalVariableId, setModalVariableId] = useState<string | null>(null)
|
|
||||||
const [selectedHistoryUnit, setSelectedHistoryUnit] = useState<string | null>(null)
|
|
||||||
const [highFrequencyRenderState, setHighFrequencyRenderState] = useState({ key: '', count: HIGH_FREQUENCY_BATCH_SIZE })
|
const [highFrequencyRenderState, setHighFrequencyRenderState] = useState({ key: '', count: HIGH_FREQUENCY_BATCH_SIZE })
|
||||||
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
||||||
const projects = useAuthStore((s) => s.projects)
|
const projects = useAuthStore((s) => s.projects)
|
||||||
@@ -266,12 +267,6 @@ export default function WellDashboardPage() {
|
|||||||
queryFn: () => fetchAssetTelemetryHistory(wellId!, 1, 100),
|
queryFn: () => fetchAssetTelemetryHistory(wellId!, 1, 100),
|
||||||
enabled: Boolean(token && wellId),
|
enabled: Boolean(token && wellId),
|
||||||
})
|
})
|
||||||
const rawTelemetryQuery = useQuery({
|
|
||||||
queryKey: ['well-dashboard-raw-telemetry', wellId],
|
|
||||||
queryFn: () => fetchAssetTelemetry(wellId!, 100),
|
|
||||||
enabled: Boolean(token && wellId),
|
|
||||||
refetchInterval: 30000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const wellConfig = useMemo(() => {
|
const wellConfig = useMemo(() => {
|
||||||
return projectConfigQuery.data?.assets.find(({ asset }) => asset.id === wellId && asset.asset_type === 'POZO') ?? null
|
return projectConfigQuery.data?.assets.find(({ asset }) => asset.id === wellId && asset.asset_type === 'POZO') ?? null
|
||||||
@@ -287,12 +282,6 @@ export default function WellDashboardPage() {
|
|||||||
|
|
||||||
return records.map(normalizeHistoryRecord)
|
return records.map(normalizeHistoryRecord)
|
||||||
}, [historyQuery.data])
|
}, [historyQuery.data])
|
||||||
const rawTelemetry = useMemo(() => {
|
|
||||||
const raw = rawTelemetryQuery.data as TelemetryHistoryRecord[] | { records?: TelemetryHistoryRecord[] } | undefined
|
|
||||||
const records = Array.isArray(raw) ? raw : Array.isArray(raw?.records) ? raw.records : []
|
|
||||||
|
|
||||||
return records.map(normalizeHistoryRecord)
|
|
||||||
}, [rawTelemetryQuery.data])
|
|
||||||
const latestAssetData = useMemo<AssetData | null>(() => {
|
const latestAssetData = useMemo<AssetData | null>(() => {
|
||||||
const asset = summaryQuery.data?.data.find((item) => item.asset_id === wellId)
|
const asset = summaryQuery.data?.data.find((item) => item.asset_id === wellId)
|
||||||
if (asset) return asset
|
if (asset) return asset
|
||||||
@@ -307,14 +296,7 @@ export default function WellDashboardPage() {
|
|||||||
telemetry: latestHistory.data,
|
telemetry: latestHistory.data,
|
||||||
}
|
}
|
||||||
}, [history, resolvedProjectId, summaryQuery.data, wellId, wellName])
|
}, [history, resolvedProjectId, summaryQuery.data, wellId, wellName])
|
||||||
const chartGroupedData = useMemo(() => sortHistoryGroups(groupChartData(history, wellId ?? 'all')), [history, wellId])
|
const chartGroupedData = useMemo(() => groupChartData(history, wellId ?? 'all'), [history, wellId])
|
||||||
const activeHistoryGroup = useMemo(() => (
|
|
||||||
chartGroupedData.find((group) => group.unit === selectedHistoryUnit) ?? chartGroupedData[0] ?? null
|
|
||||||
), [chartGroupedData, selectedHistoryUnit])
|
|
||||||
const activeHistoryGroupIndex = useMemo(() => {
|
|
||||||
if (!activeHistoryGroup) return 0
|
|
||||||
return Math.max(chartGroupedData.findIndex((group) => group.unit === activeHistoryGroup.unit), 0)
|
|
||||||
}, [activeHistoryGroup, chartGroupedData])
|
|
||||||
const variableSignals = useMemo(() => {
|
const variableSignals = useMemo(() => {
|
||||||
return enabledVariables.map((variable) => {
|
return enabledVariables.map((variable) => {
|
||||||
const value = getVariableValue(variable, latestAssetData?.telemetry)
|
const value = getVariableValue(variable, latestAssetData?.telemetry)
|
||||||
@@ -335,10 +317,10 @@ export default function WellDashboardPage() {
|
|||||||
})
|
})
|
||||||
}, [activeAlarms, enabledVariables, history, latestAssetData?.telemetry])
|
}, [activeAlarms, enabledVariables, history, latestAssetData?.telemetry])
|
||||||
const highFrequencySignals = useMemo(() => (
|
const highFrequencySignals = useMemo(() => (
|
||||||
sortVariableSignals(variableSignals.filter((signal) => signal.frequencyClass !== 'low'))
|
variableSignals.filter((signal) => signal.frequencyClass !== 'low' && typeof signal.value === 'number' && Number.isFinite(signal.value))
|
||||||
), [variableSignals])
|
), [variableSignals])
|
||||||
const highFrequencySignalIdentityKey = useMemo(() => (
|
const highFrequencySignalIdentityKey = useMemo(() => (
|
||||||
highFrequencySignals.map((signal) => getVariableKey(signal.variable)).join('|')
|
highFrequencySignals.map((signal) => signal.variable.id || signal.variable.alias).join('|')
|
||||||
), [highFrequencySignals])
|
), [highFrequencySignals])
|
||||||
const highFrequencyVisibleCount = highFrequencyRenderState.key === highFrequencySignalIdentityKey
|
const highFrequencyVisibleCount = highFrequencyRenderState.key === highFrequencySignalIdentityKey
|
||||||
? highFrequencyRenderState.count
|
? highFrequencyRenderState.count
|
||||||
@@ -346,88 +328,54 @@ export default function WellDashboardPage() {
|
|||||||
const visibleHighFrequencySignals = highFrequencySignals.slice(0, highFrequencyVisibleCount)
|
const visibleHighFrequencySignals = highFrequencySignals.slice(0, highFrequencyVisibleCount)
|
||||||
const remainingHighFrequencySignals = Math.max(highFrequencySignals.length - visibleHighFrequencySignals.length, 0)
|
const remainingHighFrequencySignals = Math.max(highFrequencySignals.length - visibleHighFrequencySignals.length, 0)
|
||||||
const lowFrequencySignals = useMemo(() => variableSignals.filter((signal) => signal.frequencyClass === 'low'), [variableSignals])
|
const lowFrequencySignals = useMemo(() => variableSignals.filter((signal) => signal.frequencyClass === 'low'), [variableSignals])
|
||||||
const lowFrequencyCaptureSignals = useMemo(() => {
|
const gaugeSignals = useMemo(() => {
|
||||||
if (!latestAssetData && history.length === 0 && rawTelemetry.length === 0) return []
|
return variableSignals
|
||||||
|
.map((signal, index) => ({ ...signal, index, priority: getGaugePriority(signal.variable, signal.label) }))
|
||||||
|
.sort((a, b) => a.priority - b.priority || a.index - b.index)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((signal) => {
|
||||||
|
const storageKey = getGaugeRangeStorageKey(wellId, signal.variable)
|
||||||
|
const defaultRange = getDefaultGaugeRange(signal.variable, signal.value, signal.sparklineValues)
|
||||||
|
const appliedRange = storedGaugeRanges[storageKey] ?? defaultRange
|
||||||
|
const status = getGaugeStatus(signal.value, appliedRange)
|
||||||
|
const fill = getGaugeFill(signal.value, appliedRange)
|
||||||
|
|
||||||
const catalogAliases = new Set(enabledVariables.map((variable) => variable.alias))
|
return {
|
||||||
const captureFieldsByAlias = new Map<string, ReturnType<typeof deriveCaptureFields>[number]>()
|
...signal,
|
||||||
const captureCandidates: AssetData[] = []
|
storageKey,
|
||||||
|
appliedRange,
|
||||||
if (latestAssetData) captureCandidates.push(latestAssetData)
|
defaultRange,
|
||||||
for (const record of [...rawTelemetry, ...history]) {
|
status,
|
||||||
if (!record.data || !wellId) continue
|
fill,
|
||||||
if (record.asset_id && record.asset_id !== wellId) continue
|
}
|
||||||
|
|
||||||
captureCandidates.push({
|
|
||||||
asset_id: wellId,
|
|
||||||
asset: wellName,
|
|
||||||
project_id: resolvedProjectId ?? undefined,
|
|
||||||
source: record.source,
|
|
||||||
frequency_class: record.frequency_class,
|
|
||||||
last_updated: record.time,
|
|
||||||
telemetry: record.data,
|
|
||||||
})
|
})
|
||||||
}
|
}, [storedGaugeRanges, variableSignals, wellId])
|
||||||
|
const selectableSignals = gaugeSignals.length > 0 ? gaugeSignals : variableSignals.slice(0, 6)
|
||||||
for (const candidate of captureCandidates) {
|
const selectedSignal = selectableSignals.find((signal) => signal.variable.id === selectedVariableId) ?? selectableSignals[0] ?? null
|
||||||
for (const field of deriveCaptureFields(candidate, catalogAliases)) {
|
|
||||||
const key = field.alias.toLowerCase()
|
|
||||||
if (!captureFieldsByAlias.has(key)) captureFieldsByAlias.set(key, field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(captureFieldsByAlias.values()).map((field) => ({
|
|
||||||
id: `capture:${field.assetId}:${field.alias}`,
|
|
||||||
label: field.label,
|
|
||||||
value: field.value,
|
|
||||||
unit: field.unit,
|
|
||||||
sourceLabel: field.source === 'PWA' ? 'Captura PWA' : 'Captura manual',
|
|
||||||
updatedAt: field.updatedAt,
|
|
||||||
status: field.value === null || field.value === undefined ? 'no-data' as const : 'normal' as const,
|
|
||||||
frequencyLabel: 'Baja frecuencia',
|
|
||||||
sparklinePath: '',
|
|
||||||
sparklineValues: [] as number[],
|
|
||||||
}))
|
|
||||||
}, [enabledVariables, history, latestAssetData, rawTelemetry, resolvedProjectId, wellId, wellName])
|
|
||||||
const allLowFrequencySignals = useMemo(() => [...lowFrequencySignals, ...lowFrequencyCaptureSignals], [lowFrequencyCaptureSignals, lowFrequencySignals])
|
|
||||||
const [stableLowFrequencySignals, setStableLowFrequencySignals] = useState<typeof allLowFrequencySignals>([])
|
|
||||||
const stableLowFrequencySnapshotKeyRef = useRef('')
|
|
||||||
const allLowFrequencySnapshotKey = useMemo(() => allLowFrequencySignals.map((signal) => {
|
|
||||||
const key = 'variable' in signal ? signal.variable.id : signal.id
|
|
||||||
const updatedAt = 'updatedAt' in signal ? signal.updatedAt : latestAssetData?.last_updated ?? ''
|
|
||||||
return `${key}:${String(signal.value)}:${updatedAt}`
|
|
||||||
}).join('|'), [allLowFrequencySignals, latestAssetData?.last_updated])
|
|
||||||
const lowFrequencyUpdating = rawTelemetryQuery.isFetching && !rawTelemetryQuery.isLoading
|
|
||||||
const visibleLowFrequencySignals = lowFrequencyUpdating && stableLowFrequencySignals.length > 0
|
|
||||||
? stableLowFrequencySignals
|
|
||||||
: allLowFrequencySignals
|
|
||||||
const selectableSignals = highFrequencySignals.length > 0 ? highFrequencySignals.slice(0, 6) : sortVariableSignals(variableSignals).slice(0, 6)
|
|
||||||
const selectedSignal = selectableSignals.find((signal) => getVariableKey(signal.variable) === selectedVariableId) ?? selectableSignals[0] ?? null
|
|
||||||
const selectedChartData = useMemo(() => {
|
const selectedChartData = useMemo(() => {
|
||||||
if (!selectedSignal) return []
|
if (!selectedSignal) return []
|
||||||
|
|
||||||
return getVariableHistoryPoints(selectedSignal.variable, history)
|
return [...history]
|
||||||
|
.reverse()
|
||||||
|
.map((record) => ({
|
||||||
|
time: formatDateTime(record.time),
|
||||||
|
value: getVariableValue(selectedSignal.variable, record.data),
|
||||||
|
}))
|
||||||
|
.filter((point): point is { time: string; value: number } => typeof point.value === 'number' && Number.isFinite(point.value))
|
||||||
}, [history, selectedSignal])
|
}, [history, selectedSignal])
|
||||||
const modalSignal = useMemo(() => {
|
|
||||||
if (!modalVariableId) return null
|
|
||||||
return variableSignals.find((signal) => getVariableKey(signal.variable) === modalVariableId) ?? null
|
|
||||||
}, [modalVariableId, variableSignals])
|
|
||||||
const modalChartData = useMemo(() => modalSignal ? getVariableHistoryPoints(modalSignal.variable, history) : [], [history, modalSignal])
|
|
||||||
const modalAverage = useMemo(() => {
|
|
||||||
if (modalChartData.length === 0) return null
|
|
||||||
return modalChartData.reduce((sum, point) => sum + point.value, 0) / modalChartData.length
|
|
||||||
}, [modalChartData])
|
|
||||||
const modalTrend = useMemo(() => {
|
|
||||||
if (modalChartData.length < 2) return null
|
|
||||||
const first = modalChartData[0].value
|
|
||||||
const last = modalChartData[modalChartData.length - 1].value
|
|
||||||
return last - first
|
|
||||||
}, [modalChartData])
|
|
||||||
const modalRange = modalSignal ? getConfiguredRange(modalSignal.variable) : null
|
|
||||||
const loading = projectConfigQuery.isLoading || summaryQuery.isLoading || historyQuery.isLoading
|
const loading = projectConfigQuery.isLoading || summaryQuery.isLoading || historyQuery.isLoading
|
||||||
const lowFrequencyLoading = loading || rawTelemetryQuery.isLoading
|
|
||||||
const hasProject = Boolean(resolvedProjectId)
|
const hasProject = Boolean(resolvedProjectId)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextRanges: Record<string, { min: number; max: number }> = {}
|
||||||
|
for (const variable of enabledVariables) {
|
||||||
|
const storageKey = getGaugeRangeStorageKey(wellId, variable)
|
||||||
|
const storedRange = readStoredGaugeRange(storageKey)
|
||||||
|
if (storedRange) nextRanges[storageKey] = storedRange
|
||||||
|
}
|
||||||
|
setStoredGaugeRanges(nextRanges)
|
||||||
|
}, [enabledVariables, wellId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHighFrequencyRenderState((current) => (
|
setHighFrequencyRenderState((current) => (
|
||||||
current.key === highFrequencySignalIdentityKey
|
current.key === highFrequencySignalIdentityKey
|
||||||
@@ -455,48 +403,29 @@ export default function WellDashboardPage() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [highFrequencySignalIdentityKey, highFrequencySignals.length, highFrequencyVisibleCount])
|
}, [highFrequencySignalIdentityKey, highFrequencySignals.length, highFrequencyVisibleCount])
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleVariable = (varName: string) => {
|
||||||
if (chartGroupedData.length === 0) {
|
|
||||||
if (selectedHistoryUnit !== null) setSelectedHistoryUnit(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedHistoryUnit || !chartGroupedData.some((group) => group.unit === selectedHistoryUnit)) {
|
|
||||||
setSelectedHistoryUnit(chartGroupedData[0].unit)
|
|
||||||
}
|
|
||||||
}, [chartGroupedData, selectedHistoryUnit])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (allLowFrequencySignals.length > 0) {
|
|
||||||
if (stableLowFrequencySnapshotKeyRef.current !== allLowFrequencySnapshotKey) {
|
|
||||||
stableLowFrequencySnapshotKeyRef.current = allLowFrequencySnapshotKey
|
|
||||||
setStableLowFrequencySignals(allLowFrequencySignals)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lowFrequencyLoading && !lowFrequencyUpdating) {
|
|
||||||
if (stableLowFrequencySnapshotKeyRef.current !== '' || stableLowFrequencySignals.length > 0) {
|
|
||||||
stableLowFrequencySnapshotKeyRef.current = ''
|
|
||||||
setStableLowFrequencySignals([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [allLowFrequencySignals, allLowFrequencySnapshotKey, lowFrequencyLoading, lowFrequencyUpdating, stableLowFrequencySignals.length])
|
|
||||||
|
|
||||||
const toggleVariable = useCallback((varName: string) => {
|
|
||||||
setVisibleVars((prev) => ({ ...prev, [varName]: prev[varName] === false }))
|
setVisibleVars((prev) => ({ ...prev, [varName]: prev[varName] === false }))
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!modalSignal) return undefined
|
|
||||||
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setModalVariableId(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', closeOnEscape)
|
const updateGaugeDraft = (storageKey: string, field: 'min' | 'max', value: string, fallback: { min: number; max: number }) => {
|
||||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
setDraftGaugeRanges((prev) => ({
|
||||||
}, [modalSignal])
|
...prev,
|
||||||
|
[storageKey]: {
|
||||||
|
min: prev[storageKey]?.min ?? formatRangeValue(fallback.min),
|
||||||
|
max: prev[storageKey]?.max ?? formatRangeValue(fallback.max),
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyGaugeRange = (storageKey: string, range: { min: number; max: number }) => {
|
||||||
|
writeStoredGaugeRange(storageKey, range)
|
||||||
|
setStoredGaugeRanges((prev) => ({ ...prev, [storageKey]: range }))
|
||||||
|
setDraftGaugeRanges((prev) => {
|
||||||
|
const { [storageKey]: _removed, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasProject) {
|
if (!hasProject) {
|
||||||
return (
|
return (
|
||||||
@@ -548,6 +477,97 @@ export default function WellDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section aria-label="Gauges de variables críticas" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#080b0f] p-4 shadow-2xl shadow-black/30">
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#7d8da1]">Telemetría crítica</p>
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-[#f6f8fb]">Variables principales</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#f59e0b]">
|
||||||
|
Última señal {formatDateTime(latestAssetData?.last_updated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{gaugeSignals.length === 0 ? (
|
||||||
|
<DashboardEmptyState
|
||||||
|
icon={Gauge}
|
||||||
|
label="Variables"
|
||||||
|
title="Sin variables configuradas"
|
||||||
|
description="Este pozo no tiene variables habilitadas para construir gauges reales."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{gaugeSignals.map(({ variable, label, value, storageKey, appliedRange, defaultRange, status, fill }) => {
|
||||||
|
const formattedValue = formatVariableValue(value)
|
||||||
|
const draftRange = draftGaugeRanges[storageKey]
|
||||||
|
const minInput = draftRange?.min ?? formatRangeValue(appliedRange.min)
|
||||||
|
const maxInput = draftRange?.max ?? formatRangeValue(appliedRange.max)
|
||||||
|
const parsedMin = Number(minInput)
|
||||||
|
const parsedMax = Number(maxInput)
|
||||||
|
const isRangeValid = Number.isFinite(parsedMin) && Number.isFinite(parsedMax) && parsedMin < parsedMax
|
||||||
|
const rangeSourceLabel = defaultRange.source === 'metadata' ? 'Rango configurado' : defaultRange.source === 'history' ? 'Rango inferido del historial' : 'Rango editable estimado'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={variable.id} aria-label={`${label}: ${formattedValue} ${variable.unit ?? ''}`} className="rounded-[1.5rem] border border-[#222b36] bg-[#11161d] p-4 shadow-inner shadow-white/[0.03]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-[#f6f8fb]">{label}</p>
|
||||||
|
<p className="mt-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#7d8da1]">{rangeSourceLabel}</p>
|
||||||
|
</div>
|
||||||
|
<span aria-label={`Estado ${status}`} className={`h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto mt-2 h-[112px] max-w-[190px]">
|
||||||
|
<svg viewBox="0 0 176 100" role="img" aria-label={`Gauge de ${label}`} className="h-full w-full">
|
||||||
|
<path d={gaugeArcPath} fill="none" stroke="#27313d" strokeLinecap="round" strokeWidth="15" />
|
||||||
|
<path d={gaugeArcPath} fill="none" pathLength="100" stroke="#f97316" strokeDasharray={`${fill} 100`} strokeLinecap="round" strokeWidth="15" />
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-x-0 bottom-2 text-center">
|
||||||
|
<div className="text-3xl font-semibold tabular-nums text-[#f6f8fb]">{formattedValue}</div>
|
||||||
|
{variable.unit && <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#8b98aa]">{variable.unit}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-center text-xs text-[#9aa8ba]">
|
||||||
|
rango {formatRangeValue(appliedRange.min)}-{formatRangeValue(appliedRange.max)}{variable.unit ? ` ${variable.unit}` : ''}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||||
|
<label className="space-y-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7d8da1]">
|
||||||
|
Min
|
||||||
|
<input
|
||||||
|
aria-label={`Mínimo ${label}`}
|
||||||
|
className="h-8 w-full rounded-lg border border-[#2a3038] bg-[#080b0f] px-2 text-xs font-medium text-[#f6f8fb] outline-none focus:border-[#f97316]"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={minInput}
|
||||||
|
onChange={(event) => updateGaugeDraft(storageKey, 'min', event.target.value, appliedRange)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7d8da1]">
|
||||||
|
Max
|
||||||
|
<input
|
||||||
|
aria-label={`Máximo ${label}`}
|
||||||
|
className="h-8 w-full rounded-lg border border-[#2a3038] bg-[#080b0f] px-2 text-xs font-medium text-[#f6f8fb] outline-none focus:border-[#f97316]"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={maxInput}
|
||||||
|
onChange={(event) => updateGaugeDraft(storageKey, 'max', event.target.value, appliedRange)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-5 h-8 rounded-lg bg-[#f97316] px-3 text-xs font-semibold text-[#11161d] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!isRangeValid}
|
||||||
|
onClick={() => applyGaugeRange(storageKey, { min: parsedMin, max: parsedMax })}
|
||||||
|
>
|
||||||
|
Aplicar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!isRangeValid && <p className="mt-2 text-xs font-medium text-[#f59e0b]">El mínimo debe ser menor que el máximo.</p>}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-label="Tendencia seleccionada y últimas alertas" className="grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.8fr)]">
|
<section aria-label="Tendencia seleccionada y últimas alertas" className="grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.8fr)]">
|
||||||
<article className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
|
<article className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
|
||||||
<div className="flex flex-col gap-3 border-b border-[#2a3038] pb-4 xl:flex-row xl:items-start xl:justify-between">
|
<div className="flex flex-col gap-3 border-b border-[#2a3038] pb-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
@@ -561,10 +581,10 @@ export default function WellDashboardPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectableSignals.map((signal) => (
|
{selectableSignals.map((signal) => (
|
||||||
<button
|
<button
|
||||||
key={getVariableKey(signal.variable)}
|
key={signal.variable.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-full border px-3 py-1.5 text-[11px] font-semibold transition-colors ${selectedSignal && getVariableKey(selectedSignal.variable) === getVariableKey(signal.variable) ? 'border-[#00b7ff]/50 bg-[#00b7ff]/15 text-[#f6f8fb]' : 'border-[#2a3038] bg-[#080b0f] text-[#9aa8ba] hover:border-[#475569]'}`}
|
className={`rounded-full border px-3 py-1.5 text-[11px] font-semibold transition-colors ${selectedSignal?.variable.id === signal.variable.id ? 'border-[#00b7ff]/50 bg-[#00b7ff]/15 text-[#f6f8fb]' : 'border-[#2a3038] bg-[#080b0f] text-[#9aa8ba] hover:border-[#475569]'}`}
|
||||||
onClick={() => setSelectedVariableId(getVariableKey(signal.variable))}
|
onClick={() => setSelectedVariableId(signal.variable.id)}
|
||||||
>
|
>
|
||||||
{signal.label}
|
{signal.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -646,9 +666,7 @@ export default function WellDashboardPage() {
|
|||||||
{highFrequencySignals.length} señales reales
|
{highFrequencySignals.length} señales reales
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{loading && highFrequencySignals.length === 0 ? (
|
{highFrequencySignals.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4 text-sm text-[#9aa8ba]">Cargando variables de alta frecuencia…</p>
|
|
||||||
) : highFrequencySignals.length === 0 ? (
|
|
||||||
<DashboardEmptyState
|
<DashboardEmptyState
|
||||||
icon={Database}
|
icon={Database}
|
||||||
label="Variables"
|
label="Variables"
|
||||||
@@ -659,20 +677,11 @@ export default function WellDashboardPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid overflow-hidden rounded-[1.25rem] border border-[#202833] bg-[#0b0f14] sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
|
<div className="grid overflow-hidden rounded-[1.25rem] border border-[#202833] bg-[#0b0f14] sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
|
||||||
{visibleHighFrequencySignals.map(({ variable, label, value, sparklinePath, sparklineValues, status }) => {
|
{visibleHighFrequencySignals.map(({ variable, label, value, sparklinePath, sparklineValues, status }) => {
|
||||||
const variableKey = getVariableKey(variable)
|
|
||||||
const formattedValue = formatVariableValue(value)
|
const formattedValue = formatVariableValue(value)
|
||||||
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
|
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={variable.id} aria-label={`${label}: ${formattedValue} ${variable.unit ?? ''}`} className="min-h-[104px] border-b border-r border-[#202833] bg-[#0b0f14] p-3 last:border-r-0">
|
||||||
key={variableKey}
|
|
||||||
type="button"
|
|
||||||
data-variable-key={variableKey}
|
|
||||||
aria-label={`Abrir detalle de ${label}: ${formattedValue} ${variable.unit ?? ''}`}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
className="h-[104px] overflow-hidden border-b border-r border-[#202833] bg-[#0b0f14] p-3 text-left transition-colors last:border-r-0 hover:bg-[#111923] focus:outline-none focus:ring-2 focus:ring-[#00b7ff] focus:ring-inset"
|
|
||||||
onClick={() => setModalVariableId(variableKey)}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-[10px] font-semibold uppercase tracking-[0.14em] text-[#8b98aa]">{label}</span>
|
<span className="truncate text-[10px] font-semibold uppercase tracking-[0.14em] text-[#8b98aa]">{label}</span>
|
||||||
<span aria-label={`Estado ${status}`} className={`h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
|
<span aria-label={`Estado ${status}`} className={`h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
|
||||||
@@ -688,16 +697,9 @@ export default function WellDashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<p className="mt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-[#64748b]">Sin historial</p>
|
<p className="mt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-[#64748b]">Sin historial</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{Array.from({ length: remainingHighFrequencySignals }, (_, index) => (
|
|
||||||
<div
|
|
||||||
key={`hf-placeholder-${index}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="invisible h-[104px] border-b border-r border-[#202833] bg-[#0b0f14] p-3 last:border-r-0"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{remainingHighFrequencySignals > 0 && (
|
{remainingHighFrequencySignals > 0 && (
|
||||||
<p className="rounded-2xl border border-[#202833] bg-[#0b0f14] px-4 py-3 text-xs font-medium text-[#8b98aa]">
|
<p className="rounded-2xl border border-[#202833] bg-[#0b0f14] px-4 py-3 text-xs font-medium text-[#8b98aa]">
|
||||||
@@ -708,47 +710,36 @@ export default function WellDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Variables de baja frecuencia" className="min-h-[320px] space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
|
<section aria-label="Variables de baja frecuencia" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Lecturas lentas</p>
|
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Lecturas lentas</p>
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-[#f6f8fb]">Variables de baja frecuencia</h2>
|
<h2 className="text-xl font-semibold tracking-tight text-[#f6f8fb]">Variables de baja frecuencia</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">{lowFrequencySignals.length} configuradas</span>
|
||||||
{lowFrequencyUpdating ? (
|
|
||||||
<span className="rounded-full border border-[#2a3038] bg-[#080b0f] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#9aa8ba]">Actualizando lecturas lentas…</span>
|
|
||||||
) : null}
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">{visibleLowFrequencySignals.length} señales</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{lowFrequencySignals.length === 0 ? (
|
||||||
{lowFrequencyLoading && visibleLowFrequencySignals.length === 0 ? (
|
<p className="rounded-2xl border border-[#2a3038] bg-[#080b0f] p-4 text-sm text-[#9aa8ba]">No hay variables de baja frecuencia configuradas para este pozo.</p>
|
||||||
<div className="flex min-h-[210px] items-center rounded-2xl border border-[#2a3038] bg-[#080b0f] p-4 text-sm text-[#9aa8ba]">Cargando variables de baja frecuencia…</div>
|
|
||||||
) : visibleLowFrequencySignals.length === 0 ? (
|
|
||||||
<div className="flex min-h-[210px] items-center rounded-2xl border border-[#2a3038] bg-[#080b0f] p-4 text-sm text-[#9aa8ba]">No hay variables de baja frecuencia ni capturas LF para este pozo.</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid min-h-[210px] gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{visibleLowFrequencySignals.map((signal) => {
|
{lowFrequencySignals.map(({ variable, label, value, sparklinePath, sparklineValues, status, frequencyLabel }) => {
|
||||||
const { label, value, sparklinePath, sparklineValues, status, frequencyLabel } = signal
|
|
||||||
const unit = 'variable' in signal ? signal.variable.unit : signal.unit
|
|
||||||
const key = 'variable' in signal ? signal.variable.id : signal.id
|
|
||||||
const formattedValue = formatVariableValue(value)
|
const formattedValue = formatVariableValue(value)
|
||||||
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
|
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={key} className="rounded-[1.25rem] border border-[#2a3038] bg-[#0b0f14] p-4">
|
<article key={variable.id} className="rounded-[1.25rem] border border-[#2a3038] bg-[#0b0f14] p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-[#f6f8fb]">{label}</p>
|
<p className="truncate text-sm font-semibold text-[#f6f8fb]">{label}</p>
|
||||||
{'sourceLabel' in signal ? <p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#00b7ff]">{signal.sourceLabel}</p> : null}
|
|
||||||
<p className="mt-1 text-xs text-[#8b98aa]">Esperada: {frequencyLabel}</p>
|
<p className="mt-1 text-xs text-[#8b98aa]">Esperada: {frequencyLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<span aria-label={`Estado ${status}`} className={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
|
<span aria-label={`Estado ${status}`} className={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-baseline gap-2">
|
<div className="mt-4 flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-semibold tabular-nums text-[#f6f8fb]">{formattedValue}</span>
|
<span className="text-2xl font-semibold tabular-nums text-[#f6f8fb]">{formattedValue}</span>
|
||||||
{unit && <span className="text-[10px] font-semibold uppercase tracking-wide text-[#8b98aa]">{unit}</span>}
|
{variable.unit && <span className="text-[10px] font-semibold uppercase tracking-wide text-[#8b98aa]">{variable.unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-[#8b98aa]">Última actualización: {formatDateTime('updatedAt' in signal ? signal.updatedAt : latestAssetData?.last_updated)}</p>
|
<p className="mt-2 text-xs text-[#8b98aa]">Última actualización: {formatDateTime(latestAssetData?.last_updated)}</p>
|
||||||
{hasSparkline ? (
|
{hasSparkline ? (
|
||||||
<svg viewBox="0 0 100 32" role="img" aria-label={`Tendencia de baja frecuencia ${label}`} className="mt-3 h-8 w-full text-[#7b8798]">
|
<svg viewBox="0 0 100 32" role="img" aria-label={`Tendencia de baja frecuencia ${label}`} className="mt-3 h-8 w-full text-[#7b8798]">
|
||||||
<path d={sparklinePath} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
<path d={sparklinePath} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
||||||
@@ -763,37 +754,14 @@ export default function WellDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Histórico del pozo" className="min-h-[520px] space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
|
<section aria-label="Histórico del pozo" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Histórico</p>
|
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Histórico</p>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Mini tendencia</h2>
|
<h2 className="text-sm font-semibold text-foreground">Mini tendencia</h2>
|
||||||
<p className="mt-1 text-xs text-[#8b98aa]">Filtrá por grupo para mantener una lectura estable sin remontar todas las gráficas.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{historyQuery.isFetching && !historyQuery.isLoading ? (
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<span className="rounded-full border border-[#2a3038] bg-[#080b0f] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#9aa8ba]">Actualizando histórico…</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chartGroupedData.length > 1 ? (
|
|
||||||
<div aria-label="Filtros de grupos históricos" className="flex flex-wrap gap-2 rounded-[1.25rem] border border-[#2a3038] bg-[#080b0f] p-2">
|
|
||||||
{chartGroupedData.map((group) => (
|
|
||||||
<button
|
|
||||||
key={group.unit}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedHistoryUnit(group.unit)}
|
|
||||||
className={`rounded-full border px-3 py-1.5 text-[11px] font-semibold transition-colors ${activeHistoryGroup?.unit === group.unit ? 'border-[#00b7ff]/50 bg-[#00b7ff]/15 text-[#f6f8fb]' : 'border-[#2a3038] bg-[#11161d] text-[#9aa8ba] hover:border-[#475569]'}`}
|
|
||||||
aria-pressed={activeHistoryGroup?.unit === group.unit}
|
|
||||||
>
|
|
||||||
{group.unit} · {group.variables.length} vars
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-h-[390px]">
|
|
||||||
{chartGroupedData.length === 0 ? (
|
{chartGroupedData.length === 0 ? (
|
||||||
<div className="flex min-h-[390px] items-center justify-center rounded-[1.25rem] border border-[#2a3038] bg-[#080b0f]">
|
<div className="col-span-full">
|
||||||
<DashboardEmptyState
|
<DashboardEmptyState
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
label="Histórico"
|
label="Histórico"
|
||||||
@@ -801,116 +769,21 @@ export default function WellDashboardPage() {
|
|||||||
description="La tendencia aparecerá cuando existan muestras históricas para este pozo."
|
description="La tendencia aparecerá cuando existan muestras históricas para este pozo."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : activeHistoryGroup ? (
|
) : (
|
||||||
<div className="min-h-[390px]">
|
chartGroupedData.map((group, index) => (
|
||||||
<TelemetryChart
|
<TelemetryChart
|
||||||
group={activeHistoryGroup}
|
key={group.unit}
|
||||||
gIdx={activeHistoryGroupIndex}
|
group={group}
|
||||||
|
gIdx={index}
|
||||||
visibleVars={visibleVars}
|
visibleVars={visibleVars}
|
||||||
toggleVariable={toggleVariable}
|
toggleVariable={toggleVariable}
|
||||||
getBgForUnit={getUnitBackgroundClass}
|
getBgForUnit={getUnitBackgroundClass}
|
||||||
getColorForUnit={getUnitColorClass}
|
getColorForUnit={getUnitColorClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
))
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{modalSignal ? (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-3 sm:p-6" role="presentation" onMouseDown={() => setModalVariableId(null)}>
|
|
||||||
<section
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="well-variable-modal-title"
|
|
||||||
className="max-h-[92vh] w-full max-w-6xl overflow-y-auto rounded-[1.75rem] border border-[#2a3038] bg-[#080b0f] p-4 text-[#f6f8fb] shadow-2xl shadow-black sm:p-6"
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 border-b border-[#202833] pb-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#00b7ff]">Detalle de variable</p>
|
|
||||||
<h2 id="well-variable-modal-title" className="mt-1 truncate text-2xl font-semibold tracking-tight">{modalSignal.label}</h2>
|
|
||||||
<p className="mt-1 text-sm text-[#9aa8ba]">Tag: {modalSignal.variable.alias} · Pozo: {wellName}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Cerrar detalle de variable"
|
|
||||||
className="rounded-full border border-[#2a3038] bg-[#11161d] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-[#f6f8fb] transition-colors hover:border-[#00b7ff]/60"
|
|
||||||
onClick={() => setModalVariableId(null)}
|
|
||||||
>
|
|
||||||
Cerrar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Último valor</p>
|
|
||||||
<div className="mt-2 flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-semibold tabular-nums">{formatVariableValue(modalSignal.value)}</span>
|
|
||||||
{modalSignal.variable.unit && <span className="text-xs font-semibold uppercase tracking-wide text-[#8b98aa]">{modalSignal.variable.unit}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Última actualización</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">{formatDateTime(latestAssetData?.last_updated)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Estado / calidad</p>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<span className={`h-2.5 w-2.5 rounded-full ${variableStatusClasses[modalSignal.status]}`} />
|
|
||||||
<span className="text-lg font-semibold">{variableStatusLabels[modalSignal.status]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Frecuencia</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">{modalSignal.frequencyLabel}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Rango configurado</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">
|
|
||||||
{modalRange ? `${formatRangeValue(modalRange.min)}-${formatRangeValue(modalRange.max)}${modalSignal.variable.unit ? ` ${modalSignal.variable.unit}` : ''}` : 'Sin rango configurado'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Promedio histórico</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">
|
|
||||||
{modalAverage === null ? 'Sin histórico' : `${formatRangeValue(modalAverage)}${modalSignal.variable.unit ? ` ${modalSignal.variable.unit}` : ''}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[#202833] bg-[#0b0f14] p-4">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Tendencia</p>
|
|
||||||
<p className="mt-2 text-lg font-semibold">
|
|
||||||
{modalTrend === null ? 'Sin tendencia' : `${modalTrend >= 0 ? '+' : ''}${formatRangeValue(modalTrend)}${modalSignal.variable.unit ? ` ${modalSignal.variable.unit}` : ''}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 h-[420px] rounded-[1.25rem] border border-[#202833] bg-[#090b0f] p-3">
|
|
||||||
{modalChartData.length === 0 ? (
|
|
||||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm font-medium text-[#9aa8ba]">
|
|
||||||
Sin histórico disponible para esta variable.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={modalChartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.07)" vertical={false} />
|
|
||||||
<XAxis dataKey="time" stroke="rgba(255,255,255,0.28)" fontSize={11} tickLine={false} axisLine={false} minTickGap={36} />
|
|
||||||
<YAxis stroke="rgba(255,255,255,0.28)" fontSize={11} tickLine={false} axisLine={false} width={42} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: '#11161d', border: '1px solid #2a3038', borderRadius: '10px', color: '#f6f8fb' }}
|
|
||||||
itemStyle={{ fontSize: '12px' }}
|
|
||||||
labelStyle={{ color: '#9aa8ba', fontSize: '11px' }}
|
|
||||||
/>
|
|
||||||
<Line type="linear" dataKey="value" name={modalSignal.label} stroke="#00b7ff" strokeWidth={2.75} dot={false} activeDot={{ r: 4, strokeWidth: 0 }} isAnimationActive={false} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getTelemetryFrequencyClass, getTelemetrySource, normalizeTelemetry, type AssetData, type TelemetryFrequencyClass } from './telemetry-normalization'
|
import { normalizeTelemetry, type AssetData } from './telemetry-normalization'
|
||||||
import type { AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
|
import type { AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
|
||||||
import type { EdgeVariable } from '@/features/admin/projects/api/variables-api'
|
import type { EdgeVariable } from '@/features/admin/projects/api/variables-api'
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@ export interface ComplianceStatus {
|
|||||||
export interface DashboardStatus {
|
export interface DashboardStatus {
|
||||||
color: string
|
color: string
|
||||||
label: string
|
label: string
|
||||||
linkLabel?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetOption {
|
export interface AssetOption {
|
||||||
@@ -90,6 +89,7 @@ export interface WellOverviewCard {
|
|||||||
variablesCount: number
|
variablesCount: number
|
||||||
criticalCount: number
|
criticalCount: number
|
||||||
hasTelemetry: boolean
|
hasTelemetry: boolean
|
||||||
|
sparklineValues: number[]
|
||||||
lastUpdated?: string
|
lastUpdated?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +184,6 @@ function getAlarmSeverity(alarmType: string): RecentAlertItem['severity'] {
|
|||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveCriticalAlarm(alarm: AlarmEvent): boolean {
|
|
||||||
return !alarm.acknowledged && getAlarmSeverity(alarm.alarm_type) === 'critical'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeverityLabel(severity: RecentAlertItem['severity']): string {
|
function getSeverityLabel(severity: RecentAlertItem['severity']): string {
|
||||||
if (severity === 'critical') return 'Crítica'
|
if (severity === 'critical') return 'Crítica'
|
||||||
if (severity === 'warning') return 'Advertencia'
|
if (severity === 'warning') return 'Advertencia'
|
||||||
@@ -294,34 +290,45 @@ export function buildVariableCategoryHealth(
|
|||||||
.sort((a, b) => a.category.localeCompare(b.category, 'es'))
|
.sort((a, b) => a.category.localeCompare(b.category, 'es'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordBelongsToWell(record: HistoryRecord, wellId: string, selectedAsset: string | 'all'): boolean {
|
||||||
|
const recordAssetId = getRecordAssetId(record)
|
||||||
|
if (recordAssetId) return recordAssetId === wellId
|
||||||
|
return selectedAsset === wellId
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstTelemetryValue(telemetryData: unknown): number | null {
|
||||||
|
const normalized = normalizeTelemetry(telemetryData)
|
||||||
|
const firstValue = Object.values(normalized)[0]?.value
|
||||||
|
return typeof firstValue === 'number' && Number.isFinite(firstValue) ? firstValue : null
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWellOverviewCards(
|
export function buildWellOverviewCards(
|
||||||
wellAssets: WellOverviewSource[],
|
wellAssets: WellOverviewSource[],
|
||||||
assets: AssetData[],
|
assets: AssetData[],
|
||||||
variables: VariableCatalogItem[],
|
|
||||||
alarms: AlarmEvent[],
|
alarms: AlarmEvent[],
|
||||||
|
history: HistoryRecord[],
|
||||||
|
selectedAsset: string | 'all',
|
||||||
projectName?: string | null
|
projectName?: string | null
|
||||||
): WellOverviewCard[] {
|
): WellOverviewCard[] {
|
||||||
const enabledVariablesByWell = variables.reduce((counts, variable) => {
|
|
||||||
if (variable.is_enabled === false) return counts
|
|
||||||
|
|
||||||
counts.set(variable.assetId, (counts.get(variable.assetId) ?? 0) + 1)
|
|
||||||
return counts
|
|
||||||
}, new Map<string, number>())
|
|
||||||
|
|
||||||
return wellAssets.map((well) => {
|
return wellAssets.map((well) => {
|
||||||
const assetData = findAssetDataForWell(assets, well.id)
|
const assetData = findAssetDataForWell(assets, well.id)
|
||||||
const activeCriticalAlarms = alarms.filter((alarm) => alarm.asset_id === well.id && isActiveCriticalAlarm(alarm))
|
|
||||||
const variablesCount = enabledVariablesByWell.get(well.id) ?? 0
|
|
||||||
const telemetryVariables = assetData ? Object.keys(normalizeTelemetry(assetData.telemetry)) : []
|
const telemetryVariables = assetData ? Object.keys(normalizeTelemetry(assetData.telemetry)) : []
|
||||||
|
const openAlarms = alarms.filter((alarm) => alarm.asset_id === well.id && !alarm.acknowledged)
|
||||||
|
const sparklineValues = history
|
||||||
|
.filter((record) => recordBelongsToWell(record, well.id, selectedAsset))
|
||||||
|
.map((record) => firstTelemetryValue(record.data))
|
||||||
|
.filter((value): value is number => value !== null)
|
||||||
|
.slice(-12)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: well.id,
|
id: well.id,
|
||||||
name: getWellDisplayName(well),
|
name: getWellDisplayName(well),
|
||||||
context: getWellContext(well, projectName),
|
context: getWellContext(well, projectName),
|
||||||
isActive: Boolean(well.is_active),
|
isActive: Boolean(well.is_active),
|
||||||
variablesCount,
|
variablesCount: telemetryVariables.length,
|
||||||
criticalCount: activeCriticalAlarms.length,
|
criticalCount: openAlarms.length,
|
||||||
hasTelemetry: telemetryVariables.length > 0,
|
hasTelemetry: telemetryVariables.length > 0,
|
||||||
|
sparklineValues,
|
||||||
lastUpdated: assetData?.last_updated,
|
lastUpdated: assetData?.last_updated,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -385,38 +392,6 @@ export function countTelemetryVariables(assets: AssetData[], wellAssets: Connect
|
|||||||
return variableKeys.size
|
return variableKeys.size
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAssetFrequencyBucket(asset: AssetData): TelemetryFrequencyClass | null {
|
|
||||||
const frequencyClass = getTelemetryFrequencyClass(asset)
|
|
||||||
if (frequencyClass) return frequencyClass
|
|
||||||
|
|
||||||
const source = getTelemetrySource(asset)
|
|
||||||
if (source === 'SCADA') return 'HF'
|
|
||||||
if (source === 'PWA' || source === 'MANUAL') return 'LF'
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function countTelemetryVariablesByFrequency(assets: AssetData[], wellAssets: ConnectedWellSource[]) {
|
|
||||||
const buckets: Record<TelemetryFrequencyClass, Set<string>> = {
|
|
||||||
HF: new Set<string>(),
|
|
||||||
LF: new Set<string>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
getWellScopedAssets(assets, wellAssets).forEach((asset) => {
|
|
||||||
const bucket = getAssetFrequencyBucket(asset)
|
|
||||||
if (!bucket) return
|
|
||||||
|
|
||||||
// Panorama signal counts intentionally mirror signalCount: unique normalized
|
|
||||||
// telemetry aliases scoped to well assets, excluding origin metadata fields.
|
|
||||||
Object.keys(normalizeTelemetry(asset.telemetry)).forEach((key) => buckets[bucket].add(key))
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
highFrequencySignalCount: buckets.HF.size,
|
|
||||||
lowFrequencySignalCount: buckets.LF.size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPanoramaSummary(
|
export function buildPanoramaSummary(
|
||||||
assets: AssetData[],
|
assets: AssetData[],
|
||||||
wellAssets: ConnectedWellSource[],
|
wellAssets: ConnectedWellSource[],
|
||||||
@@ -425,7 +400,6 @@ export function buildPanoramaSummary(
|
|||||||
): PanoramaSummary {
|
): PanoramaSummary {
|
||||||
const connectedWellCount = countConnectedWells(assets, wellAssets)
|
const connectedWellCount = countConnectedWells(assets, wellAssets)
|
||||||
const signalCount = countTelemetryVariables(assets, wellAssets)
|
const signalCount = countTelemetryVariables(assets, wellAssets)
|
||||||
const { highFrequencySignalCount, lowFrequencySignalCount } = countTelemetryVariablesByFrequency(assets, wellAssets)
|
|
||||||
const variablesMetricCount = monitoredVariableCount ?? signalCount
|
const variablesMetricCount = monitoredVariableCount ?? signalCount
|
||||||
const activeAlarmCount = alarms.filter((alarm) => !alarm.acknowledged).length
|
const activeAlarmCount = alarms.filter((alarm) => !alarm.acknowledged).length
|
||||||
|
|
||||||
@@ -436,8 +410,10 @@ export function buildPanoramaSummary(
|
|||||||
{ label: 'Pozos', value: wellAssets.length, sub: 'en operación', icon: Gauge },
|
{ label: 'Pozos', value: wellAssets.length, sub: 'en operación', icon: Gauge },
|
||||||
{ label: 'Variables', value: variablesMetricCount, sub: 'monitoreadas', icon: Database },
|
{ label: 'Variables', value: variablesMetricCount, sub: 'monitoreadas', icon: Database },
|
||||||
{ label: 'Alertas', value: activeAlarmCount, sub: 'activas', icon: AlertTriangle, accent: 'text-[#ffb020]' },
|
{ label: 'Alertas', value: activeAlarmCount, sub: 'activas', icon: AlertTriangle, accent: 'text-[#ffb020]' },
|
||||||
{ label: 'Alta frec.', value: highFrequencySignalCount, sub: 'señales SCADA', icon: Activity, accent: 'text-[#00b7ff]' },
|
{ label: 'Alta frec.', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-[#00b7ff]' },
|
||||||
{ label: 'Baja frec.', value: lowFrequencySignalCount, sub: 'señales PWA/manual', icon: Activity, accent: 'text-[#27d796]' },
|
{ label: 'Baja frec.', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-[#27d796]' },
|
||||||
|
{ label: 'Con falla', value: null, sub: 'sin fuente configurada', icon: AlertTriangle, accent: 'text-[#ff4655]' },
|
||||||
|
{ label: 'Producción', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-primary' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,21 +428,6 @@ function belongsToSelectedAsset(record: HistoryRecord, selectedAsset: string | '
|
|||||||
return !assetId || assetId === selectedAsset
|
return !assetId || assetId === selectedAsset
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyRecordFrequencyClass(record: HistoryRecord): TelemetryFrequencyClass | null {
|
|
||||||
if (!record.data) return null
|
|
||||||
return getTelemetryFrequencyClass({
|
|
||||||
asset_id: String(getRecordAssetId(record) || ''),
|
|
||||||
asset: '',
|
|
||||||
last_updated: record.time,
|
|
||||||
telemetry: record.data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function belongsToFrequencyClass(record: HistoryRecord, frequencyClass?: TelemetryFrequencyClass | null): boolean {
|
|
||||||
if (!frequencyClass) return true
|
|
||||||
return historyRecordFrequencyClass(record) === frequencyClass
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupChartData(history: HistoryRecord[], selectedAsset: string | 'all'): ChartGroup[] {
|
export function groupChartData(history: HistoryRecord[], selectedAsset: string | 'all'): ChartGroup[] {
|
||||||
if (history.length === 0) return []
|
if (history.length === 0) return []
|
||||||
|
|
||||||
@@ -506,36 +467,24 @@ export function getComplianceStatus(
|
|||||||
history: HistoryRecord[],
|
history: HistoryRecord[],
|
||||||
selectedAsset: string | 'all',
|
selectedAsset: string | 'all',
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
latencyMs = 0,
|
latencyMs = 0
|
||||||
frequencyClass?: TelemetryFrequencyClass | null
|
|
||||||
): ComplianceStatus {
|
): ComplianceStatus {
|
||||||
const startOfToday = new Date(now)
|
const startOfToday = new Date(now)
|
||||||
startOfToday.setHours(0, 0, 0, 0)
|
startOfToday.setHours(0, 0, 0, 0)
|
||||||
const assetHistory = history.filter((record) => (
|
const assetHistory = history.filter((record) => belongsToSelectedAsset(record, selectedAsset))
|
||||||
belongsToSelectedAsset(record, selectedAsset) && belongsToFrequencyClass(record, frequencyClass)
|
|
||||||
))
|
|
||||||
const samplesToday = assetHistory.filter((record) => new Date(record.time) >= startOfToday).length
|
const samplesToday = assetHistory.filter((record) => new Date(record.time) >= startOfToday).length
|
||||||
const latestRecord = assetHistory.reduce<HistoryRecord | null>((latest, record) => {
|
const latestRecord = assetHistory[0]
|
||||||
if (!latest) return record
|
|
||||||
return new Date(record.time).getTime() > new Date(latest.time).getTime() ? record : latest
|
|
||||||
}, null)
|
|
||||||
const lastUpdate = latestRecord ? new Date(latestRecord.time) : null
|
const lastUpdate = latestRecord ? new Date(latestRecord.time) : null
|
||||||
const diffSeconds = lastUpdate ? (now.getTime() - lastUpdate.getTime()) / 1000 : 9999
|
const diffSeconds = lastUpdate ? (now.getTime() - lastUpdate.getTime()) / 1000 : 9999
|
||||||
const freshnessWindowSeconds = frequencyClass === 'LF' ? 24 * 60 * 60 : 30
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
samplesToday,
|
samplesToday,
|
||||||
isStable: diffSeconds < freshnessWindowSeconds,
|
isStable: diffSeconds < 30,
|
||||||
latencyMs: lastUpdate ? latencyMs : 0,
|
latencyMs: lastUpdate ? latencyMs : 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboardStatus(isConnected: boolean, isStable: boolean, frequencyClass?: TelemetryFrequencyClass | null): DashboardStatus {
|
export function getDashboardStatus(isConnected: boolean, isStable: boolean): DashboardStatus {
|
||||||
if (frequencyClass === 'LF') {
|
|
||||||
if (!isStable) return { color: 'text-[#ffb020]', label: 'LF retrasada', linkLabel: 'Telemetría LF retrasada' }
|
|
||||||
return { color: 'text-[#27d796]', label: 'LF vigente', linkLabel: 'Telemetría LF vigente' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConnected) return { color: 'text-[#ff4655]', label: 'Desconectado' }
|
if (!isConnected) return { color: 'text-[#ff4655]', label: 'Desconectado' }
|
||||||
if (!isStable) return { color: 'text-[#ffb020]', label: 'Señal retrasada' }
|
if (!isStable) return { color: 'text-[#ffb020]', label: 'Señal retrasada' }
|
||||||
return { color: 'text-[#27d796]', label: 'Operacional' }
|
return { color: 'text-[#27d796]', label: 'Operacional' }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user