4 Commits

Author SHA1 Message Date
henryor
34754810ba feat(telemetry): split silence alarms by origin 2026-06-27 08:46:14 -05:00
henryor
c3668d047f feat(telemetry): persist scada mqtt by resolved device 2026-06-27 08:46:03 -05:00
henryor
6315307e4a fix(telemetry): persist lf source with project access 2026-06-27 08:45:50 -05:00
henryor
1f940e8164 feat(telemetry): add source contract schema 2026-06-27 08:45:37 -05:00
197 changed files with 3911 additions and 31837 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)) = (&params.ts, &params.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(&params.version) { let current = match Version::parse(&params.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)) = (&params.ts, &params.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());

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, &current);
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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