Compare commits
25 Commits
Actualizac
...
a592001f92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a592001f92 | ||
|
|
69f6d52c31 | ||
|
|
aad9ca1a8a | ||
|
|
ad7e4d7a1d | ||
|
|
6dd824a81b | ||
|
|
62e667bbbd | ||
|
|
4c9f71a267 | ||
|
|
01c34e2632 | ||
|
|
b28ed40426 | ||
|
|
c27c48775b | ||
|
|
e9002c882d | ||
|
|
2929c09a00 | ||
|
|
760f7b2dab | ||
|
|
d0e40f6a6e | ||
|
|
e9cac2ab22 | ||
|
|
6b6447a7b1 | ||
|
|
9f8c5e8e7b | ||
|
|
36f671590c | ||
|
|
cdd052a534 | ||
|
|
fff3894d24 | ||
|
|
0b760e612b | ||
|
|
f87db0fe9a | ||
|
|
228a1364ad | ||
|
|
668b73bfb9 | ||
|
|
cc9cc956c6 |
14
.clippy.toml
14
.clippy.toml
@@ -1,14 +0,0 @@
|
|||||||
# Clippy configuration for OmniOil workspace
|
|
||||||
# https://doc.rust-lang.org/clippy/configuration.html
|
|
||||||
|
|
||||||
# Disallow unwrap() in production code (allowed in tests)
|
|
||||||
disallowed-methods = [
|
|
||||||
{ path = "std::option::Option::unwrap", reason = "Use ? operator or pattern matching instead" },
|
|
||||||
{ path = "std::result::Result::unwrap", reason = "Use ? operator or pattern matching instead" },
|
|
||||||
]
|
|
||||||
|
|
||||||
# Warn on complexity
|
|
||||||
cognitive-complexity-threshold = 25
|
|
||||||
|
|
||||||
# Allow certain patterns that are intentional
|
|
||||||
allow-unwrap-in-tests = true
|
|
||||||
@@ -58,7 +58,6 @@ MOSQUITTO_GO_AUTH=true # Habilita autenticaci
|
|||||||
|
|
||||||
# --- SEGURIDAD Y TÚNELES ---
|
# --- SEGURIDAD Y TÚNELES ---
|
||||||
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
||||||
ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app # Revisores de solicitudes de acceso
|
|
||||||
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
|
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
|
||||||
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
||||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS
|
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS
|
||||||
@@ -67,10 +66,6 @@ VITE_LANDING_APP_ORIGIN=http://localhost:3004/
|
|||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||||
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
||||||
|
|
||||||
# --- SEGURIDAD DE DESCARGA DE AGENTES (OTA) ---
|
|
||||||
AGENT_DOWNLOAD_SECRET=clave_secreta_para_firmar_descargas # HMAC secret para firmar URLs de descarga de agentes
|
|
||||||
# Si no se configura, las descargas operan sin autenticación
|
|
||||||
|
|
||||||
# --- ORQUESTACIÓN Y REGISTRY ---
|
# --- ORQUESTACIÓN Y REGISTRY ---
|
||||||
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
|
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
|
||||||
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC
|
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC
|
||||||
|
|||||||
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
@@ -1,63 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: -D warnings
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rust-check:
|
|
||||||
name: Rust Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
components: rustfmt, clippy
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt -- --check
|
|
||||||
- name: Run clippy
|
|
||||||
run: cargo clippy --workspace -- -D warnings
|
|
||||||
- name: Run cargo check
|
|
||||||
run: cargo check --workspace
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --workspace
|
|
||||||
|
|
||||||
frontend-lint:
|
|
||||||
name: Frontend Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
app: [frontend-admin, frontend-pwa, landing]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: services/${{ matrix.app }}/package-lock.json
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
working-directory: services/${{ matrix.app }}
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint
|
|
||||||
working-directory: services/${{ matrix.app }}
|
|
||||||
- name: TypeScript check
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
working-directory: services/${{ matrix.app }}
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
docker-build:
|
|
||||||
name: Docker Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [rust-check]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Build Docker images
|
|
||||||
run: docker compose build
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v5.0.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-added-large-files
|
|
||||||
- id: check-merge-conflict
|
|
||||||
- id: detect-private-key
|
|
||||||
|
|
||||||
- repo: https://github.com/doublify/pre-commit-rust
|
|
||||||
rev: v1.0
|
|
||||||
hooks:
|
|
||||||
- id: fmt
|
|
||||||
- id: cargo-check
|
|
||||||
79
Cargo.lock
generated
79
Cargo.lock
generated
@@ -971,8 +971,6 @@ dependencies = [
|
|||||||
"csv",
|
"csv",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"governor",
|
"governor",
|
||||||
"hex",
|
|
||||||
"hmac 0.12.1",
|
|
||||||
"json-generator",
|
"json-generator",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -1170,11 +1168,9 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"hex",
|
"hex",
|
||||||
"prometheus",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rumqttc",
|
"rumqttc",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1183,7 +1179,6 @@ dependencies = [
|
|||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1925,7 +1920,7 @@ dependencies = [
|
|||||||
"tray-icon",
|
"tray-icon",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
"winit",
|
"winit",
|
||||||
"winres",
|
"winres",
|
||||||
"zstd",
|
"zstd",
|
||||||
@@ -2050,17 +2045,14 @@ name = "events-relay"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"prometheus",
|
|
||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3249,7 +3241,7 @@ dependencies = [
|
|||||||
"image-webp",
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png 0.18.1",
|
||||||
"qoi",
|
"qoi",
|
||||||
"ravif",
|
"ravif",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3413,19 +3405,16 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"hex",
|
"hex",
|
||||||
"prometheus",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3827,9 +3816,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.3"
|
version = "0.17.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
|
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -3841,9 +3830,9 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4658,6 +4647,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.17.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -4849,27 +4851,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prometheus"
|
|
||||||
version = "0.13.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"fnv",
|
|
||||||
"lazy_static",
|
|
||||||
"memchr",
|
|
||||||
"parking_lot",
|
|
||||||
"protobuf",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protobuf"
|
|
||||||
version = "2.28.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptr_meta"
|
name = "ptr_meta"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -6457,17 +6438,14 @@ name = "telemetry-ingestor"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"prometheus",
|
|
||||||
"rumqttc",
|
"rumqttc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -7016,16 +6994,6 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-serde"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"tracing-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
@@ -7036,22 +7004,19 @@ dependencies = [
|
|||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
"tracing-serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tray-icon"
|
name = "tray-icon"
|
||||||
version = "0.24.1"
|
version = "0.22.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
checksum = "76b42a907631429634f9f57ef72f9e7aec3e12e74050636138acb3752ecb8df3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -7063,7 +7028,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.18.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ chrono-tz = "0.10.4"
|
|||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
||||||
rumqttc = { version = "0.25.1", features = ["use-rustls"] }
|
rumqttc = { version = "0.25.1", features = ["use-rustls"] }
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
@@ -42,7 +42,6 @@ tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"
|
|||||||
s7 = "0.1.2"
|
s7 = "0.1.2"
|
||||||
semver = "1.0"
|
semver = "1.0"
|
||||||
shared-lib = { path = "services/shared-lib" }
|
shared-lib = { path = "services/shared-lib" }
|
||||||
prometheus = "0.13"
|
|
||||||
|
|
||||||
# ── Disk-space optimized profiles ──────────────────────────────────────────────
|
# ── Disk-space optimized profiles ──────────────────────────────────────────────
|
||||||
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
|
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
|
||||||
|
|||||||
@@ -20,19 +20,19 @@ 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
|
||||||
|
# Copy the rest of the source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# --- 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 +48,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 +58,7 @@ ENV VITE_ACCESS_REQUEST_REVIEWER_EMAILS=$VITE_ACCESS_REQUEST_REVIEWER_EMAILS
|
|||||||
RUN pnpm --filter frontend-console build
|
RUN pnpm --filter frontend-console build
|
||||||
|
|
||||||
# --- Stage 6: PWA build ---
|
# --- Stage 6: PWA build ---
|
||||||
FROM builder_base AS builder-pwa
|
FROM builder AS builder-pwa
|
||||||
COPY services/frontend-pwa services/frontend-pwa
|
|
||||||
ARG VITE_API_BASE
|
ARG VITE_API_BASE
|
||||||
ARG VITE_ADMIN_APP_ORIGIN
|
ARG VITE_ADMIN_APP_ORIGIN
|
||||||
ARG VITE_MOBILE_APP_ORIGIN
|
ARG VITE_MOBILE_APP_ORIGIN
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ RUN cargo chef prepare --recipe-path recipe.json
|
|||||||
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
||||||
FROM base AS cacher
|
FROM base AS cacher
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
|
||||||
--mount=type=cache,target=/app/target \
|
|
||||||
cargo chef cook --release --recipe-path recipe.json
|
|
||||||
|
|
||||||
# 3a. Cook default workspace dependencies (Linux GNU)
|
# 3a. Cook default workspace dependencies (Linux GNU)
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
|||||||
76
Makefile
76
Makefile
@@ -1,76 +0,0 @@
|
|||||||
.PHONY: all build test lint format check dev up down clean help
|
|
||||||
|
|
||||||
all: help
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Usage: make <target>"
|
|
||||||
@echo ""
|
|
||||||
@echo "Targets:"
|
|
||||||
@echo " build Build all Rust services (release)"
|
|
||||||
@echo " build-dev Build all Rust services (debug)"
|
|
||||||
@echo " test Run all cargo tests"
|
|
||||||
@echo " test-pkg Run tests for a specific package: make test-pkg PKG=shared-lib"
|
|
||||||
@echo " lint Run all linters (cargo fmt --check + npm run lint)"
|
|
||||||
@echo " fmt Format all Rust code (cargo fmt)"
|
|
||||||
@echo " check Run cargo check on all packages"
|
|
||||||
@echo " dev Start full dev stack (docker compose up)"
|
|
||||||
@echo " up Start production stack"
|
|
||||||
@echo " down Stop all containers"
|
|
||||||
@echo " clean Remove build artifacts"
|
|
||||||
@echo " logs Tail logs from a service: make logs SVC=backend-api"
|
|
||||||
@echo " db-migrate Run database migrations"
|
|
||||||
@echo " npm-install Install npm dependencies for all frontends"
|
|
||||||
@echo " ci Full CI pipeline (check + test + lint)"
|
|
||||||
|
|
||||||
build:
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
build-dev:
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
test:
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
test-pkg:
|
|
||||||
cargo test -p $(PKG)
|
|
||||||
|
|
||||||
lint:
|
|
||||||
cargo fmt -- --check
|
|
||||||
cd services/frontend-admin && npm run lint 2>/dev/null || true
|
|
||||||
cd services/frontend-pwa && npm run lint 2>/dev/null || true
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
check:
|
|
||||||
cargo check --workspace
|
|
||||||
|
|
||||||
dev:
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
|
||||||
|
|
||||||
dev-build:
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
|
||||||
|
|
||||||
up:
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
down:
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
clean:
|
|
||||||
cargo clean
|
|
||||||
rm -rf services/frontend-*/node_modules 2>/dev/null || true
|
|
||||||
|
|
||||||
logs:
|
|
||||||
docker compose logs -f $(SVC)
|
|
||||||
|
|
||||||
db-migrate:
|
|
||||||
cargo run -p backend-api --bin migrate
|
|
||||||
|
|
||||||
npm-install:
|
|
||||||
cd services/frontend-admin && npm install
|
|
||||||
cd services/frontend-pwa && npm install
|
|
||||||
cd services/frontend-console && npm install
|
|
||||||
cd services/landing && npm install
|
|
||||||
|
|
||||||
ci: check test lint
|
|
||||||
@@ -113,8 +113,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
expose:
|
|
||||||
- "9092"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -136,8 +134,6 @@ services:
|
|||||||
- MQTT_PORT=1883
|
- MQTT_PORT=1883
|
||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
expose:
|
|
||||||
- "9091"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
timescaledb:
|
timescaledb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -161,8 +157,6 @@ services:
|
|||||||
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
- AWS_REGION=${AWS_REGION:-us-east-1}
|
- AWS_REGION=${AWS_REGION:-us-east-1}
|
||||||
expose:
|
|
||||||
- "9093"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -287,8 +281,6 @@ services:
|
|||||||
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
||||||
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
||||||
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
|
||||||
expose:
|
|
||||||
- "9094"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
||||||
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
||||||
@@ -325,7 +317,6 @@ services:
|
|||||||
- '--web.enable-lifecycle'
|
- '--web.enable-lifecycle'
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
- ./infrastructure/monitoring/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
|
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
@@ -354,44 +345,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- prometheus
|
- prometheus
|
||||||
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:3.6.0
|
|
||||||
container_name: anh_loki
|
|
||||||
restart: always
|
|
||||||
command: -config.file=/etc/loki/loki-config.yml
|
|
||||||
volumes:
|
|
||||||
- ./infrastructure/monitoring/loki-config.yml:/etc/loki/loki-config.yml:ro
|
|
||||||
- loki_data:/loki
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:3.6.0
|
|
||||||
container_name: anh_promtail
|
|
||||||
restart: always
|
|
||||||
command: -config.file=/etc/promtail/promtail-config.yml
|
|
||||||
volumes:
|
|
||||||
- ./infrastructure/monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
|
|
||||||
alertmanager:
|
|
||||||
image: prom/alertmanager:v0.28.1
|
|
||||||
container_name: anh_alertmanager
|
|
||||||
restart: always
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
|
||||||
- '--storage.path=/alertmanager'
|
|
||||||
volumes:
|
|
||||||
- ./infrastructure/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
|
||||||
- alertmanager_data:/alertmanager
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
|
|
||||||
node-exporter:
|
node-exporter:
|
||||||
image: prom/node-exporter:v1.9.1
|
image: prom/node-exporter:v1.9.1
|
||||||
container_name: anh_node_exporter
|
container_name: anh_node_exporter
|
||||||
@@ -423,5 +376,3 @@ volumes:
|
|||||||
installer_output:
|
installer_output:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
loki_data:
|
|
||||||
alertmanager_data:
|
|
||||||
|
|||||||
@@ -1,115 +1,132 @@
|
|||||||
# Simulador de Carga Modbus TCP
|
# 📊 Simulador Multiprotocolo Unificado (Modbus TCP & Siemens S7)
|
||||||
|
|
||||||
`tests/simulators/unified_simulator.py` es el simulador soportado para pruebas locales de carga Modbus TCP en OmniOil. Genera variables petroleras sintéticas para varios pozos, expone un servidor Modbus TCP, publica un dashboard/API HTTP y crea un CSV compatible con la importación del backend.
|
Este documento detalla el funcionamiento, configuración y uso del **Simulador Multiprotocolo Unificado** (`tests/simulators/unified_simulator.py`) desarrollado para facilitar las pruebas locales y el desarrollo dentro del ecosistema **OmniOil**.
|
||||||
|
|
||||||
> Este script es **Modbus TCP-only**. No levanta Siemens S7 ni un simulador multiprotocolo. La restauración de S7 queda fuera del alcance de esta herramienta.
|
El simulador unifica los flujos de simulación industrial en un único script concurrente, evitando tener que levantar múltiples procesos por separado. Además, incorpora un **servidor HTTP con dashboard web interactivo** para visualizar las señales de telemetría en tiempo real.
|
||||||
|
|
||||||
## Servicios expuestos
|
---
|
||||||
|
|
||||||
| Servicio | Protocolo | Endpoint | Descripción |
|
## 🔌 Puertos de Exposición y Servicios
|
||||||
|---|---|---|---|
|
|
||||||
| Modbus TCP | Modbus TCP | `127.0.0.1:5020` | Un `unit_id` por pozo, con Holding/Input Registers base 0. |
|
|
||||||
| Dashboard | HTTP | `http://127.0.0.1:8085` | Panel web para observar estado y muestras en vivo. |
|
|
||||||
| API | HTTP JSON | `http://127.0.0.1:8085/api/data` | Snapshot de estado para smoke tests y desarrollo. |
|
|
||||||
|
|
||||||
## Instalación
|
El simulador expone tres servicios en puertos distintos sobre la interfaz de red local (`localhost` / `0.0.0.0`):
|
||||||
|
|
||||||
Usá Python 3.8+ y fijá la versión de `pymodbus`:
|
| Servicio | Protocolo | Puerto | Descripción |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Modbus TCP Server** | Modbus TCP | **`5020`** | Servidor de registros Modbus (Holding/Input Registers) para adquisición de datos. |
|
||||||
|
| **Siemens S7 Server** | S7 (RFC1006) | **`1102`** | Servidor PLC simulado con área de datos DB1 activa para lectura de bloques. |
|
||||||
|
| **Dashboard & API** | HTTP (Web) | **`8085`** | Panel visual interactivo y API JSON para desarrollo rápido. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Requisitos e Instalación
|
||||||
|
|
||||||
|
El simulador requiere **Python 3.8+** y un conjunto de dependencias de comunicación industrial.
|
||||||
|
|
||||||
|
### 1. Entorno Virtual de Python (Recomendado)
|
||||||
|
Desde la raíz del proyecto, activa tu entorno virtual:
|
||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # Linux/WSL/macOS
|
source venv/bin/activate # En Linux/WSL/macOS
|
||||||
# o: venv\Scripts\activate # Windows
|
# o: venv\Scripts\activate en Windows
|
||||||
|
|
||||||
pip install "pymodbus==3.6.9"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
La versión `3.6.9` es intencional: el simulador usa `ModbusSlaveContext.setValues` para actualizar registros en vivo. Versiones más nuevas de `pymodbus` cambiaron esa API y pueden romper la actualización dinámica.
|
### 2. Dependencias de Python
|
||||||
|
Instala las dependencias necesarias:
|
||||||
|
```bash
|
||||||
|
pip install pymodbus python-snap7
|
||||||
|
```
|
||||||
|
|
||||||
## Ejecución
|
### 3. Requisito del Sistema para Siemens S7 (`snap7`)
|
||||||
|
La librería `python-snap7` es un wrapper de la librería nativa en C **Snap7**. Por lo tanto, el sistema operativo necesita el binario de Snap7 para poder instanciar el servidor S7:
|
||||||
|
|
||||||
Desde la raíz del repositorio:
|
#### En Linux (Ubuntu / WSL / Debian):
|
||||||
|
```bash
|
||||||
|
sudo add-apt-repository ppa:gijzelaar/snap7
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install libsnap7-dev libsnap7-1
|
||||||
|
```
|
||||||
|
*Nota: Si estás ejecutando en Linux, el puerto S7 estándar es el 102. Como ese puerto es menor que 1024, requiere privilegios de superusuario (`sudo`). Para facilitar el desarrollo y evitar ejecutar como root, este simulador expone S7 en el puerto alternativo **`1102`**.*
|
||||||
|
|
||||||
|
#### En Windows o macOS:
|
||||||
|
Descarga el binario compilado de `snap7.dll` o `libsnap7.dylib` desde el [repositorio oficial de SourceForge de Snap7](https://sourceforge.net/projects/snap7/) y colócalo en el directorio del sistema (por ejemplo, `C:\Windows\System32`) o agrégalo a tus variables de entorno (`PATH` o `LD_LIBRARY_PATH`).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Resiliencia:** Si no tienes configurado `snap7` en tu sistema, el script unificado capturará el error de forma segura, informará de la falta del binario por consola, y **levantará exitosamente** los servidores Modbus TCP (5020) y HTTP Dashboard (8085) sin colapsar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ejecución del Simulador
|
||||||
|
|
||||||
|
Para iniciar todos los simuladores concurrentemente, ejecuta el script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tests/simulators/unified_simulator.py
|
python tests/simulators/unified_simulator.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Al iniciar, el script:
|
### Comportamiento al iniciar:
|
||||||
|
1. Imprime el estado de carga de cada protocolo por consola.
|
||||||
|
2. Inicia el servidor HTTP en el puerto `8085`.
|
||||||
|
3. Cada **5 segundos** genera nuevos valores aleatorios realistas y actualiza la memoria interna de los servidores Modbus y S7.
|
||||||
|
4. Imprime los valores simulados en la terminal con un formato limpio.
|
||||||
|
|
||||||
1. Construye 5 pozos con 200 variables por pozo.
|
---
|
||||||
2. Genera `tests/simulators/variables_import.csv`.
|
|
||||||
3. Levanta el dashboard/API HTTP en el puerto `8085`.
|
|
||||||
4. Si `pymodbus==3.6.9` está instalado, levanta Modbus TCP en el puerto `5020`.
|
|
||||||
5. Actualiza variables de alta frecuencia cada 1 segundo y baja frecuencia cada 30 segundos.
|
|
||||||
|
|
||||||
Si `pymodbus` no está instalado o no coincide con `3.6.9`, el script muestra una advertencia clara. El CSV y el dashboard pueden seguir funcionando, pero el servidor Modbus TCP queda desactivado.
|
## 📊 Mapeo y Estructura de Datos Simulados
|
||||||
|
|
||||||
## Contrato Modbus
|
Los valores cambian dinámicamente cada 5 segundos simulando condiciones industriales en tiempo real:
|
||||||
|
|
||||||
- Host recomendado para clientes locales: `127.0.0.1`.
|
### 1. Modbus TCP (Puerto `5020`, ID de Esclavo por defecto: `0x00`)
|
||||||
- Puerto: `5020`.
|
|
||||||
- Unit IDs: `1..N`, uno por pozo. Con la configuración por defecto: `1..5`.
|
|
||||||
- Direcciones: base 0, usando el formato `HR:<address>` o `IR:<address>`.
|
|
||||||
- Ejemplos válidos: `HR:0`, `HR:1`, `IR:0`, `IR:1`.
|
|
||||||
- Tipos de dato generados para importación: `uint16`.
|
|
||||||
|
|
||||||
Este contrato coincide con el flujo de lectura del edge-agent: el `UnitId` identifica el pozo/PLC dentro del mismo endpoint TCP y `Address` indica el banco (`HR` o `IR`) más la dirección raw base 0.
|
* **Holding Registers (FC 3):**
|
||||||
|
* `HR1` [Registro `40001`]: Temperatura simulada. Rango: `500` a `9500`.
|
||||||
|
* `HR2` [Registro `40002`]: Presión simulada. Rango: `500` a `9500`.
|
||||||
|
* `HR3` [Registro `40003`]: Flujo simulado. Rango: `500` a `9500`.
|
||||||
|
* `HR4` [Registro `40004`]: Nivel simulado. Rango: `500` a `9500`.
|
||||||
|
* `HR5` [Registro `40005`]: Estado simulado. Rango: `500` a `9500`.
|
||||||
|
* **Input Registers (FC 4):**
|
||||||
|
* `IR1` [Registro `30001`]: Voltaje simulado. Rango: `1000` a `15000`.
|
||||||
|
* `IR2` [Registro `30002`]: Corriente simulada. Rango: `1000` a `15000`.
|
||||||
|
* `IR3` [Registro `30003`]: Frecuencia simulada. Rango: `1000` a `15000`.
|
||||||
|
|
||||||
## API HTTP
|
### 2. Siemens S7 (Puerto `1102`, Rack 0, Slot 1)
|
||||||
|
|
||||||
`GET http://127.0.0.1:8085/api/data` devuelve un snapshot con esta forma:
|
* **Data Block 1 (DB1):**
|
||||||
|
* `DB1.DBW0` (Offset 0): Integer de 16 bits (Big Endian). Rango: `100` a `30000`.
|
||||||
|
* `DB1.DBW2` (Offset 2): Integer de 16 bits (Big Endian). Rango: `50` a `20000`.
|
||||||
|
|
||||||
|
### 3. API JSON HTTP (Puerto `8085`)
|
||||||
|
|
||||||
|
Puedes realizar una petición HTTP tipo `GET` a `http://localhost:8085/api/data` para recuperar el estado instantáneo de la simulación en formato JSON:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2026-06-25 15:00:00",
|
"timestamp": "2026-06-23 09:30:15",
|
||||||
"summary": {
|
"modbus": {
|
||||||
"total": 1000,
|
"running": true,
|
||||||
"hf": 400,
|
"holding_registers": [4832, 1289, 7891, 5421, 3312],
|
||||||
"lf": 600,
|
"input_registers": [12015, 8431, 14201]
|
||||||
"wells": 5,
|
|
||||||
"changes_last_cycle": 400,
|
|
||||||
"modbus_running": true
|
|
||||||
},
|
},
|
||||||
"wells": [
|
"s7": {
|
||||||
{ "code": "POZO-01", "unit_id": 1, "tags": 200, "hf": 80, "lf": 120 }
|
"running": true,
|
||||||
],
|
"db1_dbw0": 18451,
|
||||||
"sample": [
|
"db1_dbw2": 11342
|
||||||
{ "well": "POZO-01", "alias": "Presion_Cabezal_01", "address": "HR:0", "value": 1042, "unit": "psi" }
|
},
|
||||||
]
|
"http": {
|
||||||
|
"running": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## CSV de importación
|
---
|
||||||
|
|
||||||
El simulador genera `tests/simulators/variables_import.csv` al arrancar. Ese archivo es salida generada y está ignorado por Git; no debe commitearse.
|
## 🎨 Visualización en Navegador (Dashboard Web)
|
||||||
|
|
||||||
Headers esperados:
|
Abre tu navegador web favorito y dirígete a:
|
||||||
|
|
||||||
```text
|
👉 **`http://localhost:8085`** o **`http://localhost:8085/dashboard`**
|
||||||
WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl
|
|
||||||
```
|
|
||||||
|
|
||||||
Con la carga por defecto se generan 1000 filas: 5 pozos × 200 variables. Cada fila apunta a `127.0.0.1:5020`, usa `UnitId` `1..5` y direcciones `HR:`/`IR:` base 0.
|
### Características del Dashboard:
|
||||||
|
- **Diseño Premium:** Estética oscura moderna con efectos de traslucidez (glassmorphism) y paleta de colores curada para el sector energético.
|
||||||
## Verificación segura
|
- **Gráficas en Vivo:** Tendencias dinámicas e interactivas representadas mediante *Chart.js* con actualizaciones continuas cada 5 segundos.
|
||||||
|
- **Micro-animaciones:** Los valores parpadean con un sutil destello de color cian/púrpura en el momento exacto en el que cambia la telemetría, y los indicadores de estado pulsan en verde cuando los servidores están activos.
|
||||||
Comandos útiles para validar el contrato sin depender del backend completo:
|
- **Selectores de Señales:** Permite alternar la gráfica en tiempo real entre los Holding Registers, Input Registers o las variables S7 con un solo clic.
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m py_compile tests/simulators/unified_simulator.py
|
|
||||||
python -m unittest tests.simulators.test_unified_simulator_contract
|
|
||||||
```
|
|
||||||
|
|
||||||
Smoke manual de runtime:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install "pymodbus==3.6.9"
|
|
||||||
python tests/simulators/unified_simulator.py
|
|
||||||
curl http://127.0.0.1:8085/api/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Después de una ejecución real, revisá que el CSV generado siga fuera del control de versiones:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git status --short --ignored tests/simulators/variables_import.csv
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# Telemetry Origin Contract
|
|
||||||
|
|
||||||
OmniOil distinguishes telemetry by origin, not by UI labels or polling heuristics. The backend persists the origin, downstream services derive frequency from it, and the dashboard consumes the same contract.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
| Concept | Contract |
|
|
||||||
| --- | --- |
|
|
||||||
| High frequency | `source = SCADA` → `frequency_class = HF` |
|
|
||||||
| Low frequency | `source = PWA` or `source = MANUAL` → `frequency_class = LF` |
|
|
||||||
| Movements | Operational events, not telemetry signal |
|
|
||||||
| Unknown origin | Do not count as HF or LF |
|
|
||||||
|
|
||||||
`frequency_class` is derived from `source`; it is not an independent persisted database column.
|
|
||||||
|
|
||||||
## Write Paths
|
|
||||||
|
|
||||||
| Path | Source | Persistence rule |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Edge MQTT telemetry | `SCADA` | Resolve `(project_id, device_name)` to exactly one asset before insert. Drop unresolved or ambiguous samples with warning/metric. |
|
|
||||||
| PWA `/api/telemetry` | `PWA` | Derive `project_id` from `asset_id`, validate user project access, persist `client_id`. Reject `SCADA` over HTTP. |
|
|
||||||
| Manual HTTP telemetry | `MANUAL` | Same LF freshness semantics as PWA. |
|
|
||||||
| PWA `/api/movements` | not telemetry | Deduplicate retries by movement `client_id`; movements do not feed silence checks or HF/LF counts. |
|
|
||||||
|
|
||||||
## Freshness And Silence
|
|
||||||
|
|
||||||
| Alarm type | Origin | Default window |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `TELEMETRY_SILENCE_HF` | `SCADA` | 10 minutes |
|
|
||||||
| `TELEMETRY_SILENCE_LF` | `PWA`, `MANUAL` | 24 hours |
|
|
||||||
|
|
||||||
Legacy `TELEMETRY_SILENCE` is treated as HF/deprecated for compatibility.
|
|
||||||
|
|
||||||
## Dashboard Rules
|
|
||||||
|
|
||||||
- Panorama HF/LF cards count normalized telemetry aliases by authoritative origin.
|
|
||||||
- The dashboard does not infer HF/LF from polling interval.
|
|
||||||
- LF samples must not make the global SCADA/HF status look healthy.
|
|
||||||
- Unknown origin remains visible as telemetry but does not enter HF/LF buckets.
|
|
||||||
|
|
||||||
## Post-Deploy Checklist
|
|
||||||
|
|
||||||
Run these checks after applying migrations in an environment with TimescaleDB.
|
|
||||||
|
|
||||||
### 1. Confirm source backfill and chunks
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
(SELECT count(*) FROM telemetry_raw) AS rows,
|
|
||||||
count(*) FILTER (WHERE is_compressed) AS compressed_chunks,
|
|
||||||
count(*) AS total_chunks
|
|
||||||
FROM timescaledb_information.chunks
|
|
||||||
WHERE hypertable_name = 'telemetry_raw';
|
|
||||||
|
|
||||||
SELECT source, count(*)
|
|
||||||
FROM telemetry_raw
|
|
||||||
GROUP BY source
|
|
||||||
ORDER BY source;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: every `telemetry_raw.source` is non-null and existing rows are backfilled to `SCADA` unless written later as `PWA` or `MANUAL`.
|
|
||||||
|
|
||||||
### 2. Confirm telemetry idempotency
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT time, asset_id, count(*)
|
|
||||||
FROM telemetry_raw
|
|
||||||
GROUP BY time, asset_id
|
|
||||||
HAVING count(*) > 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: zero rows.
|
|
||||||
|
|
||||||
### 3. Confirm movement idempotency
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT asset_id, details->>'client_id' AS client_id, count(*)
|
|
||||||
FROM operation_movements
|
|
||||||
WHERE details->>'client_id' IS NOT NULL
|
|
||||||
AND details->>'client_id' <> ''
|
|
||||||
GROUP BY asset_id, details->>'client_id'
|
|
||||||
HAVING count(*) > 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: zero rows.
|
|
||||||
|
|
||||||
### 4. Watch MQTT resolution metrics
|
|
||||||
|
|
||||||
- `omnioil_telemetry_unresolved_device_total`
|
|
||||||
- `omnioil_telemetry_ambiguous_device_total`
|
|
||||||
|
|
||||||
If `ambiguous` increases, audit duplicate device names within a project before adding any structural uniqueness constraint.
|
|
||||||
|
|
||||||
## Follow-Ups
|
|
||||||
|
|
||||||
- Fix baseline TypeScript and Rust formatting gates separately if CI requires full workspace checks.
|
|
||||||
- Add structural uniqueness for `(project_id, device_name)` only after auditing current device data.
|
|
||||||
- The LF simulator is intentionally out of scope for this contract document.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
global:
|
|
||||||
resolve_timeout: 5m
|
|
||||||
|
|
||||||
route:
|
|
||||||
receiver: 'default'
|
|
||||||
group_wait: 30s
|
|
||||||
group_interval: 5m
|
|
||||||
repeat_interval: 4h
|
|
||||||
|
|
||||||
receivers:
|
|
||||||
- name: 'default'
|
|
||||||
slack_configs:
|
|
||||||
- send_resolved: true
|
|
||||||
title: '{{ .GroupLabels.alertname }}'
|
|
||||||
text: '{{ .CommonAnnotations.description }}'
|
|
||||||
@@ -6,10 +6,3 @@ datasources:
|
|||||||
url: http://prometheus:9090
|
url: http://prometheus:9090
|
||||||
isDefault: true
|
isDefault: true
|
||||||
editable: false
|
editable: false
|
||||||
|
|
||||||
- name: Loki
|
|
||||||
type: loki
|
|
||||||
access: proxy
|
|
||||||
url: http://loki:3100
|
|
||||||
isDefault: false
|
|
||||||
editable: false
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
auth_enabled: false
|
|
||||||
|
|
||||||
server:
|
|
||||||
http_listen_port: 3100
|
|
||||||
grpc_listen_port: 9096
|
|
||||||
|
|
||||||
common:
|
|
||||||
instance_addr: 0.0.0.0
|
|
||||||
path_prefix: /loki
|
|
||||||
storage:
|
|
||||||
filesystem:
|
|
||||||
chunks_directory: /loki/chunks
|
|
||||||
rules_directory: /loki/rules
|
|
||||||
replication_factor: 1
|
|
||||||
ring:
|
|
||||||
kvstore:
|
|
||||||
store: inmemory
|
|
||||||
|
|
||||||
schema_config:
|
|
||||||
configs:
|
|
||||||
- from: 2020-10-24
|
|
||||||
store: tsdb
|
|
||||||
object_store: filesystem
|
|
||||||
schema: v13
|
|
||||||
index:
|
|
||||||
prefix: index_
|
|
||||||
period: 24h
|
|
||||||
|
|
||||||
compactor:
|
|
||||||
working_directory: /loki/compactor
|
|
||||||
retention_enabled: true
|
|
||||||
|
|
||||||
limits_config:
|
|
||||||
reject_old_samples: true
|
|
||||||
reject_old_samples_max_age: 168h
|
|
||||||
|
|
||||||
ruler:
|
|
||||||
alertmanager_url: http://alertmanager:9093
|
|
||||||
enable_alertmanager_v2: true
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
groups:
|
|
||||||
- name: omnioil_alerts
|
|
||||||
interval: 30s
|
|
||||||
rules:
|
|
||||||
- alert: BackendAPIDown
|
|
||||||
expr: up{job="omnioil-api"} == 0
|
|
||||||
for: 1m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Backend API is down"
|
|
||||||
description: "Backend API has been unreachable for more than 1 minute."
|
|
||||||
|
|
||||||
- alert: HighMQTTConnectionErrors
|
|
||||||
expr: rate(omnioil_mqtt_connections_total[5m]) > 0.1
|
|
||||||
for: 2m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "High MQTT reconnection rate"
|
|
||||||
description: "MQTT is reconnecting more than once per 10 seconds."
|
|
||||||
|
|
||||||
- alert: ActiveAgentsLow
|
|
||||||
expr: omnioil_active_agents < 1
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "No active agents"
|
|
||||||
description: "There are no active edge agents reporting health."
|
|
||||||
@@ -2,14 +2,6 @@ global:
|
|||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
evaluation_interval: 15s
|
evaluation_interval: 15s
|
||||||
|
|
||||||
alerting:
|
|
||||||
alertmanagers:
|
|
||||||
- static_configs:
|
|
||||||
- targets: ['alertmanager:9093']
|
|
||||||
|
|
||||||
rule_files:
|
|
||||||
- /etc/prometheus/prometheus-rules.yml
|
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
- job_name: 'omnioil-api'
|
- job_name: 'omnioil-api'
|
||||||
static_configs:
|
static_configs:
|
||||||
@@ -17,26 +9,6 @@ scrape_configs:
|
|||||||
metrics_path: '/metrics'
|
metrics_path: '/metrics'
|
||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
|
|
||||||
- job_name: 'telemetry-ingestor'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['telemetry-ingestor:9091']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'events-relay'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['events-relay:9092']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'json-generator'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['json-generator:9093']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'build-orchestrator'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['build-orchestrator:9094']
|
|
||||||
scrape_interval: 15s
|
|
||||||
|
|
||||||
- job_name: 'node-exporter'
|
- job_name: 'node-exporter'
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['node-exporter:9100']
|
- targets: ['node-exporter:9100']
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
server:
|
|
||||||
http_listen_port: 9080
|
|
||||||
grpc_listen_port: 0
|
|
||||||
|
|
||||||
positions:
|
|
||||||
filename: /tmp/positions.yaml
|
|
||||||
|
|
||||||
clients:
|
|
||||||
- url: http://loki:3100/loki/api/v1/push
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: docker
|
|
||||||
docker_sd_configs:
|
|
||||||
- host: unix:///var/run/docker.sock
|
|
||||||
refresh_interval: 5s
|
|
||||||
relabel_configs:
|
|
||||||
- source_labels: ['__meta_docker_container_name']
|
|
||||||
regex: '/(.*)'
|
|
||||||
target_label: 'container'
|
|
||||||
- source_labels: ['__meta_docker_container_log_stream']
|
|
||||||
target_label: 'stream'
|
|
||||||
0
infrastructure/mosquitto/entrypoint.sh
Normal file → Executable file
0
infrastructure/mosquitto/entrypoint.sh
Normal file → Executable file
@@ -1,3 +0,0 @@
|
|||||||
[toolchain]
|
|
||||||
channel = "stable"
|
|
||||||
components = ["rustfmt", "clippy"]
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Run a Rust service with hot-reload (cargo-watch).
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\dev-watch.ps1 -Service backend-api
|
|
||||||
.\scripts\dev-watch.ps1 -Service telemetry-ingestor
|
|
||||||
#>
|
|
||||||
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[ValidateSet('backend-api','telemetry-ingestor','events-relay','json-generator','build-orchestrator','edge-agent','data-collector')]
|
|
||||||
[string]$Service
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Check if cargo-watch is installed
|
|
||||||
if (-not (Get-Command 'cargo-watch' -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Host "Installing cargo-watch..." -ForegroundColor Yellow
|
|
||||||
cargo install cargo-watch
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Starting $Service with hot-reload..." -ForegroundColor Green
|
|
||||||
cargo watch -x "run -p $Service"
|
|
||||||
@@ -44,5 +44,3 @@ semver = { workspace = true }
|
|||||||
axum-prometheus = "0.8"
|
axum-prometheus = "0.8"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
csv = "1.4.0"
|
csv = "1.4.0"
|
||||||
hmac = "0.12"
|
|
||||||
hex = "0.4"
|
|
||||||
|
|||||||
@@ -52,15 +52,10 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin backend-api
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
COPY . .
|
||||||
COPY --from=cacher /app /app
|
# Copiar caché del cacher
|
||||||
|
COPY --from=cacher /app/target target
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
|
||||||
COPY services/shared-lib services/shared-lib
|
|
||||||
COPY services/json-generator services/json-generator
|
|
||||||
COPY services/backend-api services/backend-api
|
|
||||||
COPY .sqlx .sqlx
|
|
||||||
RUN cargo build --release --bin backend-api
|
RUN cargo build --release --bin backend-api
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
-- 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
|
|
||||||
ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'SCADA',
|
|
||||||
ADD COLUMN IF NOT EXISTS client_id TEXT;
|
|
||||||
|
|
||||||
ALTER TABLE telemetry_raw
|
|
||||||
ALTER COLUMN source SET DEFAULT 'SCADA';
|
|
||||||
|
|
||||||
ALTER TABLE telemetry_raw
|
|
||||||
DROP CONSTRAINT IF EXISTS telemetry_raw_source_check;
|
|
||||||
|
|
||||||
ALTER TABLE telemetry_raw
|
|
||||||
ADD CONSTRAINT telemetry_raw_source_check
|
|
||||||
CHECK (source IN ('SCADA', 'PWA', 'MANUAL'));
|
|
||||||
|
|
||||||
UPDATE telemetry_raw
|
|
||||||
SET source = 'SCADA'
|
|
||||||
WHERE source IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE telemetry_raw
|
|
||||||
ALTER COLUMN source SET NOT NULL;
|
|
||||||
|
|
||||||
-- Make the idempotency index migration-safe for databases that already contain
|
|
||||||
-- exact duplicate telemetry samples. Keep one row per existing (time, asset_id)
|
|
||||||
-- pair and delete only the extra rows in those duplicate pairs. `asset_id` was
|
|
||||||
-- nullable in legacy telemetry_raw, so `IS NOT DISTINCT FROM` preserves exact
|
|
||||||
-- duplicate-pair semantics for NULL asset_id without broad cleanup. Hypertables
|
|
||||||
-- can span multiple chunks, so identify physical rows by (tableoid, ctid), not
|
|
||||||
-- ctid alone.
|
|
||||||
WITH duplicate_pairs AS (
|
|
||||||
SELECT time, asset_id
|
|
||||||
FROM telemetry_raw
|
|
||||||
GROUP BY time, asset_id
|
|
||||||
HAVING COUNT(*) > 1
|
|
||||||
), ranked_duplicates AS (
|
|
||||||
SELECT
|
|
||||||
t.tableoid,
|
|
||||||
t.ctid,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY t.time, t.asset_id ORDER BY t.tableoid, t.ctid) AS duplicate_rank
|
|
||||||
FROM telemetry_raw t
|
|
||||||
JOIN duplicate_pairs d
|
|
||||||
ON t.time IS NOT DISTINCT FROM d.time
|
|
||||||
AND t.asset_id IS NOT DISTINCT FROM d.asset_id
|
|
||||||
)
|
|
||||||
DELETE FROM telemetry_raw t
|
|
||||||
USING ranked_duplicates d
|
|
||||||
WHERE t.tableoid = d.tableoid
|
|
||||||
AND t.ctid = d.ctid
|
|
||||||
AND d.duplicate_rank > 1;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_telemetry_raw_time_asset_unique
|
|
||||||
ON telemetry_raw (time, asset_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_telemetry_raw_project_source_time
|
|
||||||
ON telemetry_raw (project_id, source, time DESC);
|
|
||||||
|
|
||||||
SELECT compress_chunk(chunk, true)
|
|
||||||
FROM _telemetry_raw_compressed_chunks_before;
|
|
||||||
|
|
||||||
DROP TABLE _telemetry_raw_compressed_chunks_before;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- Existing deployments may already contain rows inserted by offline clients before
|
|
||||||
-- the idempotency constraint existed. Those rows can share the same per-asset
|
|
||||||
-- client id and would make the unique index fail. Only reclaim that narrow class
|
|
||||||
-- of duplicate idempotency keys: keep the earliest row deterministically and
|
|
||||||
-- delete later rows for the same non-empty (asset_id, details->>'client_id').
|
|
||||||
WITH duplicate_movement_client_ids AS (
|
|
||||||
SELECT
|
|
||||||
ctid,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY asset_id, details->>'client_id'
|
|
||||||
ORDER BY created_at NULLS LAST, id
|
|
||||||
) AS duplicate_position
|
|
||||||
FROM operation_movements
|
|
||||||
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
DELETE FROM operation_movements AS movement
|
|
||||||
USING duplicate_movement_client_ids AS duplicate
|
|
||||||
WHERE movement.ctid = duplicate.ctid
|
|
||||||
AND duplicate.duplicate_position > 1;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_operation_movements_asset_client_id_unique
|
|
||||||
ON operation_movements (asset_id, (details->>'client_id'))
|
|
||||||
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
-- Customer commercial profile foundation.
|
|
||||||
-- Rollback boundary: later slices can ignore these additive tables and continue
|
|
||||||
-- reading legacy subscriptions. Existing subscription rows are not modified.
|
|
||||||
|
|
||||||
ALTER TABLE access_requests
|
|
||||||
DROP CONSTRAINT IF EXISTS access_requests_status_check;
|
|
||||||
|
|
||||||
ALTER TABLE access_requests
|
|
||||||
ADD CONSTRAINT access_requests_status_check
|
|
||||||
CHECK (status IN ('pending', 'pending_classification', 'in_review', 'approved', 'rejected'));
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS customer_commercial_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
access_request_id UUID REFERENCES access_requests(id) ON DELETE SET NULL,
|
|
||||||
project_id UUID REFERENCES edge_projects(id) ON DELETE CASCADE,
|
|
||||||
status VARCHAR(30) NOT NULL DEFAULT 'draft'
|
|
||||||
CHECK (status IN ('draft', 'in_review', 'approved', 'active', 'suspended', 'archived')),
|
|
||||||
plan VARCHAR(30) NOT NULL DEFAULT 'free'
|
|
||||||
CHECK (plan IN ('free', 'operative', 'enterprise')),
|
|
||||||
payment_method VARCHAR(40)
|
|
||||||
CHECK (payment_method IS NULL OR payment_method IN ('none', 'bank_transfer', 'manual_invoice', 'enterprise_contract')),
|
|
||||||
payment_terms_days INT CHECK (payment_terms_days IS NULL OR payment_terms_days >= 0),
|
|
||||||
credit_limit_cop BIGINT CHECK (credit_limit_cop IS NULL OR credit_limit_cop >= 0),
|
|
||||||
tg_limit NUMERIC(12, 2) CHECK (tg_limit IS NULL OR tg_limit >= 0),
|
|
||||||
pbase_cop BIGINT NOT NULL DEFAULT 110000 CHECK (pbase_cop > 0),
|
|
||||||
discount_k NUMERIC(6, 4) NOT NULL DEFAULT 0.1000 CHECK (discount_k >= 0),
|
|
||||||
currency CHAR(3) NOT NULL DEFAULT 'COP',
|
|
||||||
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
||||||
effective_to DATE CHECK (effective_to IS NULL OR effective_to >= effective_from),
|
|
||||||
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
approved_at TIMESTAMPTZ,
|
|
||||||
enterprise_annual_cop BIGINT CHECK (enterprise_annual_cop IS NULL OR enterprise_annual_cop >= 0),
|
|
||||||
enterprise_monthly_cop BIGINT CHECK (enterprise_monthly_cop IS NULL OR enterprise_monthly_cop >= 0),
|
|
||||||
enterprise_override_reason TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CHECK (access_request_id IS NOT NULL OR project_id IS NOT NULL)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_commercial_profiles_access_request
|
|
||||||
ON customer_commercial_profiles(access_request_id)
|
|
||||||
WHERE access_request_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_commercial_profiles_project
|
|
||||||
ON customer_commercial_profiles(project_id)
|
|
||||||
WHERE project_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profiles_status
|
|
||||||
ON customer_commercial_profiles(status, effective_from DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profiles_plan
|
|
||||||
ON customer_commercial_profiles(plan);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS customer_commercial_profile_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
commercial_profile_id UUID NOT NULL REFERENCES customer_commercial_profiles(id) ON DELETE CASCADE,
|
|
||||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
change_reason TEXT NOT NULL,
|
|
||||||
changed_fields TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
||||||
before_json JSONB NOT NULL DEFAULT '{}'::JSONB,
|
|
||||||
after_json JSONB NOT NULL DEFAULT '{}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profile_versions_profile
|
|
||||||
ON customer_commercial_profile_versions(commercial_profile_id, changed_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profile_versions_actor
|
|
||||||
ON customer_commercial_profile_versions(actor_user_id, changed_at DESC)
|
|
||||||
WHERE actor_user_id IS NOT NULL;
|
|
||||||
|
|
||||||
INSERT INTO customer_commercial_profiles (
|
|
||||||
project_id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k,
|
|
||||||
effective_from,
|
|
||||||
notes
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
s.project_id,
|
|
||||||
CASE WHEN s.status = 'suspended' THEN 'suspended' ELSE 'active' END,
|
|
||||||
CASE WHEN s.tier = 'enterprise' THEN 'enterprise' ELSE 'operative' END,
|
|
||||||
'manual_invoice',
|
|
||||||
30,
|
|
||||||
s.tag_limit::NUMERIC,
|
|
||||||
COALESCE(NULLIF(s.price_per_tag, 90000), 110000),
|
|
||||||
0.1000,
|
|
||||||
s.current_period_start::DATE,
|
|
||||||
'Backfilled from legacy subscription during commercial profile rollout.'
|
|
||||||
FROM subscriptions s
|
|
||||||
WHERE s.status IN ('active', 'trial', 'suspended')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS user_well_access (
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
|
||||||
well_asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
PRIMARY KEY (user_id, well_asset_id),
|
|
||||||
CONSTRAINT user_well_access_project_well_unique UNIQUE (user_id, project_id, well_asset_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_well_access_user_project
|
|
||||||
ON user_well_access (user_id, project_id)
|
|
||||||
WHERE is_active = true;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_well_access_well
|
|
||||||
ON user_well_access (well_asset_id)
|
|
||||||
WHERE is_active = true;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION validate_user_well_access_well()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM edge_assets ea
|
|
||||||
WHERE ea.id = NEW.well_asset_id
|
|
||||||
AND ea.project_id = NEW.project_id
|
|
||||||
AND ea.asset_type = 'POZO'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'user_well_access.well_asset_id must reference a POZO in the same project';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_validate_user_well_access_well ON user_well_access;
|
|
||||||
CREATE TRIGGER trg_validate_user_well_access_well
|
|
||||||
BEFORE INSERT OR UPDATE ON user_well_access
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION validate_user_well_access_well();
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
-- Migration to implement Row Level Security (RLS) and multi-tenancy constraints
|
|
||||||
-- at the database layer.
|
|
||||||
|
|
||||||
-- 1. Insert the 'superadmin' platform role with read-only statistics permissions
|
|
||||||
-- Note: 'superadmin' already exists in the system database with standard permissions,
|
|
||||||
-- this ensures it is initialized if it's a blank setup.
|
|
||||||
INSERT INTO roles (name, permissions) VALUES
|
|
||||||
('superadmin', '{"PLATFORM_ACCESS": true, "SUPER_STATS_ONLY": true}'::jsonb)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- 2. Create helper functions to extract session context
|
|
||||||
CREATE OR REPLACE FUNCTION get_current_user_id() RETURNS UUID AS $$
|
|
||||||
DECLARE
|
|
||||||
user_id_str TEXT;
|
|
||||||
BEGIN
|
|
||||||
user_id_str := current_setting('app.current_user_id', true);
|
|
||||||
IF user_id_str IS NULL OR user_id_str = '' THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
RETURN user_id_str::uuid;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION is_superadmin() RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
current_uid UUID;
|
|
||||||
is_sa BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
current_uid := get_current_user_id();
|
|
||||||
IF current_uid IS NULL THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM users u
|
|
||||||
JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.id = current_uid
|
|
||||||
AND r.name = 'superadmin'
|
|
||||||
) INTO is_sa;
|
|
||||||
|
|
||||||
RETURN is_sa;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
-- 3. Enable RLS on multi-tenant tables
|
|
||||||
ALTER TABLE edge_projects ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE edge_assets ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE edge_devices ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE edge_variables ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE operation_movements ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE operations_downtime ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE well_tests ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE lab_results ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE meter_readings ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE alert_rules ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- 4. Create RLS policies for edge_projects
|
|
||||||
DROP POLICY IF EXISTS select_edge_projects ON edge_projects;
|
|
||||||
CREATE POLICY select_edge_projects ON edge_projects
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = edge_projects.id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_edge_projects ON edge_projects;
|
|
||||||
CREATE POLICY modify_edge_projects ON edge_projects
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = edge_projects.id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = edge_projects.id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5. Helper procedure to create standard policies for child tables with 'project_id' column
|
|
||||||
CREATE OR REPLACE PROCEDURE create_child_table_rls_policies(table_name TEXT) AS $$
|
|
||||||
BEGIN
|
|
||||||
EXECUTE format('
|
|
||||||
DROP POLICY IF EXISTS select_%I ON %I;
|
|
||||||
CREATE POLICY select_%I ON %I
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = %I.project_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
', table_name, table_name, table_name, table_name, table_name);
|
|
||||||
|
|
||||||
EXECUTE format('
|
|
||||||
DROP POLICY IF EXISTS modify_%I ON %I;
|
|
||||||
CREATE POLICY modify_%I ON %I
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = %I.project_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM user_project_access upa
|
|
||||||
WHERE upa.project_id = %I.project_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
', table_name, table_name, table_name, table_name, table_name, table_name);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- 6. Apply standard RLS policies to child tables with direct 'project_id'
|
|
||||||
CALL create_child_table_rls_policies('edge_assets');
|
|
||||||
CALL create_child_table_rls_policies('alert_rules');
|
|
||||||
|
|
||||||
-- Clean up helper procedure
|
|
||||||
DROP PROCEDURE create_child_table_rls_policies(TEXT);
|
|
||||||
|
|
||||||
-- 7. Custom RLS policies for tables referencing asset_id (no direct project_id)
|
|
||||||
-- edge_devices
|
|
||||||
DROP POLICY IF EXISTS select_edge_devices ON edge_devices;
|
|
||||||
CREATE POLICY select_edge_devices ON edge_devices
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = edge_devices.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_edge_devices ON edge_devices;
|
|
||||||
CREATE POLICY modify_edge_devices ON edge_devices
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = edge_devices.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = edge_devices.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- operation_movements
|
|
||||||
DROP POLICY IF EXISTS select_operation_movements ON operation_movements;
|
|
||||||
CREATE POLICY select_operation_movements ON operation_movements
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operation_movements.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_operation_movements ON operation_movements;
|
|
||||||
CREATE POLICY modify_operation_movements ON operation_movements
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operation_movements.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operation_movements.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- operations_downtime
|
|
||||||
DROP POLICY IF EXISTS select_operations_downtime ON operations_downtime;
|
|
||||||
CREATE POLICY select_operations_downtime ON operations_downtime
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operations_downtime.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_operations_downtime ON operations_downtime;
|
|
||||||
CREATE POLICY modify_operations_downtime ON operations_downtime
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operations_downtime.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = operations_downtime.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- well_tests
|
|
||||||
DROP POLICY IF EXISTS select_well_tests ON well_tests;
|
|
||||||
CREATE POLICY select_well_tests ON well_tests
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = well_tests.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_well_tests ON well_tests;
|
|
||||||
CREATE POLICY modify_well_tests ON well_tests
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = well_tests.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = well_tests.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- lab_results
|
|
||||||
DROP POLICY IF EXISTS select_lab_results ON lab_results;
|
|
||||||
CREATE POLICY select_lab_results ON lab_results
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = lab_results.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_lab_results ON lab_results;
|
|
||||||
CREATE POLICY modify_lab_results ON lab_results
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = lab_results.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = lab_results.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- meter_readings
|
|
||||||
DROP POLICY IF EXISTS select_meter_readings ON meter_readings;
|
|
||||||
CREATE POLICY select_meter_readings ON meter_readings
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = meter_readings.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_meter_readings ON meter_readings;
|
|
||||||
CREATE POLICY modify_meter_readings ON meter_readings
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = meter_readings.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ea.id = meter_readings.asset_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 8. Custom RLS policies for edge_variables (referencing device_id -> asset_id)
|
|
||||||
DROP POLICY IF EXISTS select_edge_variables ON edge_variables;
|
|
||||||
CREATE POLICY select_edge_variables ON edge_variables
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
is_superadmin() OR
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_devices ed
|
|
||||||
JOIN edge_assets ea ON ea.id = ed.asset_id
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ed.id = edge_variables.device_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS modify_edge_variables ON edge_variables;
|
|
||||||
CREATE POLICY modify_edge_variables ON edge_variables
|
|
||||||
FOR ALL
|
|
||||||
USING (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_devices ed
|
|
||||||
JOIN edge_assets ea ON ea.id = ed.asset_id
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ed.id = edge_variables.device_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WITH CHECK (
|
|
||||||
NOT is_superadmin() AND
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM edge_devices ed
|
|
||||||
JOIN edge_assets ea ON ea.id = ed.asset_id
|
|
||||||
JOIN user_project_access upa ON upa.project_id = ea.project_id
|
|
||||||
WHERE ed.id = edge_variables.device_id
|
|
||||||
AND upa.user_id = get_current_user_id()
|
|
||||||
AND upa.is_active = true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
-- =============================================================================
|
|
||||||
-- OMNIOIL — PERFORMANCE INDEXES (v2026-07-07)
|
|
||||||
-- =============================================================================
|
|
||||||
-- Índices compuestos faltantes identificados mediante análisis de queries.
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- 1. Outbox polling: events-relay consulta cada segundo por eventos PENDING
|
|
||||||
-- ordenados por created_at (LIMIT 50). Sin índice, escanea toda la tabla.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_domain_outbox_pending
|
|
||||||
ON domain_outbox (status, created_at)
|
|
||||||
WHERE status = 'PENDING';
|
|
||||||
|
|
||||||
-- 2. Watchdog unhealthy agents: busca alarmas AGENT_OFFLINE recientes.
|
|
||||||
-- El índice existente idx_telemetry_alarms_project no es óptimo porque
|
|
||||||
-- filtra por alarm_type y timestamp, no por project_id.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_type_time
|
|
||||||
ON telemetry_alarms (alarm_type, timestamp DESC);
|
|
||||||
|
|
||||||
-- 3. Cooldown de alarmas: telemetry-ingestor verifica duplicados por
|
|
||||||
-- (asset_id, variable_alias, alarm_type) con timestamp > NOW() - 15min.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_cooldown
|
|
||||||
ON telemetry_alarms (asset_id, variable_alias, alarm_type, timestamp DESC);
|
|
||||||
|
|
||||||
-- 4. Cleanup agent_logs: DELETE masivo por timestamp.
|
|
||||||
-- El índice existente idx_agent_logs_project no cubre timestamp sola.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_logs_timestamp
|
|
||||||
ON agent_logs (timestamp DESC);
|
|
||||||
|
|
||||||
-- 5. Watchdog unhealthy agents: filtro compuesto sobre edge_projects.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_projects_watchdog
|
|
||||||
ON edge_projects (is_active, installer_status, contract_number)
|
|
||||||
WHERE is_active = true
|
|
||||||
AND installer_status = 'COMPLETED'
|
|
||||||
AND btrim(contract_number) <> '';
|
|
||||||
|
|
||||||
-- 6. Watchdog calibration: busca assets activos próximos a vencer.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_assets_calibration
|
|
||||||
ON edge_assets (project_id, is_active, next_calibration_date)
|
|
||||||
WHERE is_active = true;
|
|
||||||
@@ -99,19 +99,6 @@ pub async fn log(pool: &PgPool, entry: AuditEntry<'_>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commercial_profile_change_metadata(
|
|
||||||
before_json: serde_json::Value,
|
|
||||||
after_json: serde_json::Value,
|
|
||||||
reason: &str,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"domain": "customer_commercial_profiles",
|
|
||||||
"reason": reason,
|
|
||||||
"before": before_json,
|
|
||||||
"after": after_json,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extrae la IP del cliente de los headers de la request.
|
/// Extrae la IP del cliente de los headers de la request.
|
||||||
pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
|
pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
|
||||||
req.headers()
|
req.headers()
|
||||||
@@ -126,37 +113,3 @@ pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#[test]
|
|
||||||
fn commercial_profile_audit_metadata_preserves_before_after_and_reason() {
|
|
||||||
let before = serde_json::json!({
|
|
||||||
"status": "in_review",
|
|
||||||
"payment_terms_days": 15,
|
|
||||||
"effective_from": "2026-06-01"
|
|
||||||
});
|
|
||||||
let after = serde_json::json!({
|
|
||||||
"status": "approved",
|
|
||||||
"payment_terms_days": 30,
|
|
||||||
"effective_from": "2026-07-01",
|
|
||||||
"approved_by": "44444444-4444-4444-4444-444444444444"
|
|
||||||
});
|
|
||||||
|
|
||||||
let metadata = super::commercial_profile_change_metadata(
|
|
||||||
before.clone(),
|
|
||||||
after.clone(),
|
|
||||||
"Terms approved for July rollout",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(metadata["domain"], "customer_commercial_profiles");
|
|
||||||
assert_eq!(metadata["reason"], "Terms approved for July rollout");
|
|
||||||
assert_eq!(metadata["before"], before);
|
|
||||||
assert_eq!(metadata["after"], after);
|
|
||||||
assert_eq!(metadata["after"]["effective_from"], "2026-07-01");
|
|
||||||
assert_eq!(
|
|
||||||
metadata["after"]["approved_by"],
|
|
||||||
"44444444-4444-4444-4444-444444444444"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -390,16 +390,10 @@ pub fn role_satisfies_platform_admin_claims(role: &str) -> bool {
|
|||||||
|
|
||||||
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
|
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
|
||||||
let email = claims.email.trim().to_ascii_lowercase();
|
let email = claims.email.trim().to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
let reviewers = std::env::var("ACCESS_REQUEST_REVIEWER_EMAILS")
|
email.as_str(),
|
||||||
.unwrap_or_default();
|
"w.farfan@omnioil.app" | "h.ortegon@omnioil.app"
|
||||||
let reviewer_list: Vec<String> = reviewers
|
) {
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_ascii_lowercase())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if reviewer_list.contains(&email.to_ascii_lowercase()) {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub const DEFAULT_OPERATIVE_K: f64 = 0.10;
|
|
||||||
pub const DEFAULT_OPERATIVE_PBASE_COP: i64 = 110_000;
|
|
||||||
pub const OPERATIVE_LF_EQUIVALENT_TG: f64 = 0.4;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
|
|
||||||
pub struct OperativeBillingInput {
|
|
||||||
pub hf_tags: u32,
|
|
||||||
pub lf_tags: u32,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub k: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
pub struct OperativeBillingBreakdown {
|
|
||||||
pub hf_tags: u32,
|
|
||||||
pub lf_tags: u32,
|
|
||||||
pub equivalent_tg: f64,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub k: f64,
|
|
||||||
pub annual_cop: i64,
|
|
||||||
pub monthly_cop: i64,
|
|
||||||
pub currency: &'static str,
|
|
||||||
pub rounding_policy: &'static str,
|
|
||||||
pub effective_hf_price_cop: i64,
|
|
||||||
pub effective_lf_price_cop: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum BillingRulesError {
|
|
||||||
NonPositivePbase,
|
|
||||||
NegativeDiscountExponent,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn calculate_operative_billing_with_defaults(
|
|
||||||
hf_tags: u32,
|
|
||||||
lf_tags: u32,
|
|
||||||
) -> Result<OperativeBillingBreakdown, BillingRulesError> {
|
|
||||||
calculate_operative_billing(OperativeBillingInput {
|
|
||||||
hf_tags,
|
|
||||||
lf_tags,
|
|
||||||
pbase_cop: DEFAULT_OPERATIVE_PBASE_COP,
|
|
||||||
k: DEFAULT_OPERATIVE_K,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn calculate_operative_billing(
|
|
||||||
input: OperativeBillingInput,
|
|
||||||
) -> Result<OperativeBillingBreakdown, BillingRulesError> {
|
|
||||||
if input.pbase_cop <= 0 {
|
|
||||||
return Err(BillingRulesError::NonPositivePbase);
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.k < 0.0 {
|
|
||||||
return Err(BillingRulesError::NegativeDiscountExponent);
|
|
||||||
}
|
|
||||||
|
|
||||||
let equivalent_tg = input.hf_tags as f64 + (input.lf_tags as f64 * OPERATIVE_LF_EQUIVALENT_TG);
|
|
||||||
|
|
||||||
if equivalent_tg == 0.0 {
|
|
||||||
return Ok(OperativeBillingBreakdown {
|
|
||||||
hf_tags: input.hf_tags,
|
|
||||||
lf_tags: input.lf_tags,
|
|
||||||
equivalent_tg,
|
|
||||||
pbase_cop: input.pbase_cop,
|
|
||||||
k: input.k,
|
|
||||||
annual_cop: 0,
|
|
||||||
monthly_cop: 0,
|
|
||||||
currency: "COP",
|
|
||||||
rounding_policy: "nearest peso",
|
|
||||||
effective_hf_price_cop: 0,
|
|
||||||
effective_lf_price_cop: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let annual_cop =
|
|
||||||
round_cop(equivalent_tg * input.pbase_cop as f64 * equivalent_tg.powf(-input.k));
|
|
||||||
let monthly_cop = round_cop(annual_cop as f64 / 12.0);
|
|
||||||
let effective_hf_price_cop = if input.hf_tags > 0 {
|
|
||||||
round_cop(annual_cop as f64 / equivalent_tg)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let effective_lf_price_cop = if input.lf_tags > 0 {
|
|
||||||
round_cop((annual_cop as f64 / equivalent_tg) * OPERATIVE_LF_EQUIVALENT_TG)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(OperativeBillingBreakdown {
|
|
||||||
hf_tags: input.hf_tags,
|
|
||||||
lf_tags: input.lf_tags,
|
|
||||||
equivalent_tg,
|
|
||||||
pbase_cop: input.pbase_cop,
|
|
||||||
k: input.k,
|
|
||||||
annual_cop,
|
|
||||||
monthly_cop,
|
|
||||||
currency: "COP",
|
|
||||||
rounding_policy: "nearest peso",
|
|
||||||
effective_hf_price_cop,
|
|
||||||
effective_lf_price_cop,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn round_cop(value: f64) -> i64 {
|
|
||||||
value.round() as i64
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,8 @@
|
|||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub type DbPool = PgPool;
|
pub type DbPool = PgPool;
|
||||||
|
|
||||||
pub fn default_pool_options() -> PgPoolOptions {
|
|
||||||
PgPoolOptions::new()
|
|
||||||
.max_connections(
|
|
||||||
env::var("DB_MAX_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
)
|
|
||||||
.min_connections(
|
|
||||||
env::var("DB_MIN_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)
|
|
||||||
.idle_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(600),
|
|
||||||
))
|
|
||||||
.max_lifetime(Duration::from_secs(
|
|
||||||
env::var("DB_MAX_LIFETIME_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1800),
|
|
||||||
))
|
|
||||||
.acquire_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_ACQUIRE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
|
pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
|
||||||
@@ -45,7 +10,9 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
|
|||||||
let max_retries = 10;
|
let max_retries = 10;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match default_pool_options()
|
match PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(3))
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, NaiveDate, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -83,71 +83,6 @@ pub struct RejectAccessRequestPayload {
|
|||||||
pub reason: String,
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct CommercialClassificationPayload {
|
|
||||||
pub plan: Option<String>,
|
|
||||||
pub payment_method: Option<String>,
|
|
||||||
pub payment_terms_days: Option<i32>,
|
|
||||||
pub credit_limit_cop: Option<i64>,
|
|
||||||
pub tg_limit: Option<f64>,
|
|
||||||
pub pbase_cop: Option<i64>,
|
|
||||||
pub discount_k: Option<f64>,
|
|
||||||
pub effective_from: Option<NaiveDate>,
|
|
||||||
pub enterprise_annual_cop: Option<i64>,
|
|
||||||
pub enterprise_monthly_cop: Option<i64>,
|
|
||||||
pub enterprise_override_reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
|
||||||
pub struct CommercialProfileRow {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub access_request_id: Option<Uuid>,
|
|
||||||
pub project_id: Option<Uuid>,
|
|
||||||
pub status: String,
|
|
||||||
pub plan: String,
|
|
||||||
pub payment_method: Option<String>,
|
|
||||||
pub payment_terms_days: Option<i32>,
|
|
||||||
pub credit_limit_cop: Option<i64>,
|
|
||||||
pub tg_limit: Option<f64>,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub discount_k: f64,
|
|
||||||
pub currency: String,
|
|
||||||
pub effective_from: NaiveDate,
|
|
||||||
pub effective_to: Option<NaiveDate>,
|
|
||||||
pub approved_by: Option<Uuid>,
|
|
||||||
pub approved_at: Option<DateTime<Utc>>,
|
|
||||||
pub enterprise_annual_cop: Option<i64>,
|
|
||||||
pub enterprise_monthly_cop: Option<i64>,
|
|
||||||
pub enterprise_override_reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub profile_complete: bool,
|
|
||||||
pub missing_fields: Vec<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(test, derive(Debug, PartialEq))]
|
|
||||||
struct ClassificationPersistenceIntent {
|
|
||||||
missing_fields: Vec<&'static str>,
|
|
||||||
profile_status: &'static str,
|
|
||||||
request_status: &'static str,
|
|
||||||
approved_by: Option<Uuid>,
|
|
||||||
approved_at_should_be_set: bool,
|
|
||||||
effective_from: NaiveDate,
|
|
||||||
change_reason: String,
|
|
||||||
changed_fields: Vec<&'static str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
struct ClassificationPersistenceStatements {
|
|
||||||
profile_upsert: &'static str,
|
|
||||||
version_insert: &'static str,
|
|
||||||
request_update: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
pub struct AccessRequestRow {
|
pub struct AccessRequestRow {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -177,7 +112,6 @@ pub struct AccessRequestRow {
|
|||||||
pub invitation_expires_at: Option<DateTime<Utc>>,
|
pub invitation_expires_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_consumed_at: Option<DateTime<Utc>>,
|
pub invitation_consumed_at: Option<DateTime<Utc>>,
|
||||||
pub invitation_revoked_at: Option<DateTime<Utc>>,
|
pub invitation_revoked_at: Option<DateTime<Utc>>,
|
||||||
pub commercial_profile: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCESS_REQUEST_SELECT: &str = r#"
|
const ACCESS_REQUEST_SELECT: &str = r#"
|
||||||
@@ -191,10 +125,9 @@ const ACCESS_REQUEST_SELECT: &str = r#"
|
|||||||
inv.email_status AS invitation_email_status,
|
inv.email_status AS invitation_email_status,
|
||||||
inv.email_sent_at AS invitation_email_sent_at,
|
inv.email_sent_at AS invitation_email_sent_at,
|
||||||
sub.trial_ends_at AS trial_ends_at,
|
sub.trial_ends_at AS trial_ends_at,
|
||||||
inv.expires_at AS invitation_expires_at,
|
inv.expires_at AS invitation_expires_at,
|
||||||
inv.consumed_at AS invitation_consumed_at,
|
inv.consumed_at AS invitation_consumed_at,
|
||||||
inv.revoked_at AS invitation_revoked_at,
|
inv.revoked_at AS invitation_revoked_at
|
||||||
profile.commercial_profile AS commercial_profile
|
|
||||||
FROM access_requests ar
|
FROM access_requests ar
|
||||||
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
||||||
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
||||||
@@ -212,195 +145,17 @@ const ACCESS_REQUEST_SELECT: &str = r#"
|
|||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) inv ON true
|
) inv ON true
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT jsonb_build_object(
|
|
||||||
'id', ccp.id,
|
|
||||||
'access_request_id', ccp.access_request_id,
|
|
||||||
'project_id', ccp.project_id,
|
|
||||||
'status', ccp.status,
|
|
||||||
'plan', ccp.plan,
|
|
||||||
'payment_method', ccp.payment_method,
|
|
||||||
'payment_terms_days', ccp.payment_terms_days,
|
|
||||||
'credit_limit_cop', ccp.credit_limit_cop,
|
|
||||||
'tg_limit', ccp.tg_limit,
|
|
||||||
'pbase_cop', ccp.pbase_cop,
|
|
||||||
'discount_k', ccp.discount_k,
|
|
||||||
'currency', ccp.currency,
|
|
||||||
'effective_from', ccp.effective_from,
|
|
||||||
'effective_to', ccp.effective_to,
|
|
||||||
'enterprise_annual_cop', ccp.enterprise_annual_cop,
|
|
||||||
'enterprise_monthly_cop', ccp.enterprise_monthly_cop,
|
|
||||||
'enterprise_override_reason', ccp.enterprise_override_reason,
|
|
||||||
'profile_complete', (
|
|
||||||
ccp.plan IS NOT NULL
|
|
||||||
AND ccp.payment_method IS NOT NULL
|
|
||||||
AND (
|
|
||||||
ccp.plan = 'free'
|
|
||||||
OR (
|
|
||||||
ccp.payment_terms_days IS NOT NULL
|
|
||||||
AND ccp.tg_limit IS NOT NULL
|
|
||||||
AND ccp.effective_from IS NOT NULL
|
|
||||||
AND (
|
|
||||||
ccp.plan != 'enterprise'
|
|
||||||
OR (
|
|
||||||
ccp.enterprise_annual_cop IS NOT NULL
|
|
||||||
AND ccp.enterprise_monthly_cop IS NOT NULL
|
|
||||||
AND NULLIF(BTRIM(COALESCE(ccp.enterprise_override_reason, '')), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) AS commercial_profile
|
|
||||||
FROM customer_commercial_profiles ccp
|
|
||||||
WHERE ccp.access_request_id = ar.id
|
|
||||||
OR (ar.provisioned_project_id IS NOT NULL AND ccp.project_id = ar.provisioned_project_id)
|
|
||||||
ORDER BY ccp.updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) profile ON true
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const CLASSIFICATION_PROFILE_UPSERT_SQL: &str = r#"INSERT INTO customer_commercial_profiles (
|
|
||||||
access_request_id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
credit_limit_cop,
|
|
||||||
tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k,
|
|
||||||
effective_from,
|
|
||||||
approved_by,
|
|
||||||
approved_at,
|
|
||||||
enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason,
|
|
||||||
notes
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
COALESCE($3, 'free'),
|
|
||||||
$4,
|
|
||||||
$5,
|
|
||||||
$6,
|
|
||||||
$7,
|
|
||||||
COALESCE($8, 110000),
|
|
||||||
COALESCE($9, 0.1000),
|
|
||||||
COALESCE($10, CURRENT_DATE),
|
|
||||||
CASE WHEN $11 THEN $12 ELSE NULL END,
|
|
||||||
CASE WHEN $11 THEN NOW() ELSE NULL END,
|
|
||||||
$13,
|
|
||||||
$14,
|
|
||||||
$15,
|
|
||||||
$16
|
|
||||||
)
|
|
||||||
ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL
|
|
||||||
DO UPDATE SET
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
plan = EXCLUDED.plan,
|
|
||||||
payment_method = EXCLUDED.payment_method,
|
|
||||||
payment_terms_days = EXCLUDED.payment_terms_days,
|
|
||||||
credit_limit_cop = EXCLUDED.credit_limit_cop,
|
|
||||||
tg_limit = EXCLUDED.tg_limit,
|
|
||||||
pbase_cop = EXCLUDED.pbase_cop,
|
|
||||||
discount_k = EXCLUDED.discount_k,
|
|
||||||
effective_from = EXCLUDED.effective_from,
|
|
||||||
approved_by = EXCLUDED.approved_by,
|
|
||||||
approved_at = EXCLUDED.approved_at,
|
|
||||||
enterprise_annual_cop = EXCLUDED.enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop = EXCLUDED.enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason = EXCLUDED.enterprise_override_reason,
|
|
||||||
notes = EXCLUDED.notes,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id"#;
|
|
||||||
|
|
||||||
const COMMERCIAL_PROFILE_VERSION_INSERT_SQL: &str = r#"INSERT INTO customer_commercial_profile_versions (
|
|
||||||
commercial_profile_id,
|
|
||||||
actor_user_id,
|
|
||||||
change_reason,
|
|
||||||
changed_fields,
|
|
||||||
before_json,
|
|
||||||
after_json
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)"#;
|
|
||||||
|
|
||||||
const CLASSIFICATION_REQUEST_STATUS_UPDATE_SQL: &str = r#"UPDATE access_requests
|
|
||||||
SET status = CASE WHEN $2 THEN 'approved' ELSE 'pending_classification' END,
|
|
||||||
reviewed_by = $3,
|
|
||||||
reviewed_at = NOW(),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1"#;
|
|
||||||
|
|
||||||
fn validate_status(status: &str) -> Result<(), AppError> {
|
fn validate_status(status: &str) -> Result<(), AppError> {
|
||||||
match status {
|
match status {
|
||||||
"pending" | "in_review" | "approved" | "rejected" => Ok(()),
|
"pending" | "in_review" | "approved" | "rejected" => Ok(()),
|
||||||
"pending_classification" => Ok(()),
|
|
||||||
_ => Err(AppError::BadRequest(
|
_ => Err(AppError::BadRequest(
|
||||||
"Estado de solicitud inválido".to_string(),
|
"Estado de solicitud inválido".to_string(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_can_be_provisioned(status: &str) -> bool {
|
|
||||||
status == "approved"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classification_missing_fields(
|
|
||||||
plan: Option<&str>,
|
|
||||||
payment_method: Option<&str>,
|
|
||||||
payment_terms_days: Option<i32>,
|
|
||||||
tg_limit: Option<f64>,
|
|
||||||
pbase_cop: Option<i64>,
|
|
||||||
discount_k: Option<f64>,
|
|
||||||
effective_from: Option<NaiveDate>,
|
|
||||||
enterprise: Option<(Option<i64>, Option<i64>, Option<&str>)>,
|
|
||||||
) -> Vec<&'static str> {
|
|
||||||
let mut missing = Vec::new();
|
|
||||||
|
|
||||||
if plan.is_none_or(|value| value.trim().is_empty()) {
|
|
||||||
missing.push("plan");
|
|
||||||
}
|
|
||||||
if payment_method.is_none_or(|value| value.trim().is_empty()) {
|
|
||||||
missing.push("payment_method");
|
|
||||||
}
|
|
||||||
let is_free = matches!(plan, Some("free"));
|
|
||||||
|
|
||||||
if !is_free {
|
|
||||||
if payment_terms_days.is_none() {
|
|
||||||
missing.push("payment_terms_days");
|
|
||||||
}
|
|
||||||
if tg_limit.is_none() {
|
|
||||||
missing.push("tg_limit");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pbase_cop.is_some_and(|value| value <= 0) {
|
|
||||||
missing.push("pbase_cop");
|
|
||||||
}
|
|
||||||
if discount_k.is_some_and(|value| value < 0.0) {
|
|
||||||
missing.push("discount_k");
|
|
||||||
}
|
|
||||||
if !is_free && effective_from.is_none() {
|
|
||||||
missing.push("effective_from");
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(plan, Some("enterprise")) {
|
|
||||||
let (annual, monthly, reason) = enterprise.unwrap_or((None, None, None));
|
|
||||||
if annual.is_none() {
|
|
||||||
missing.push("enterprise_annual_cop");
|
|
||||||
}
|
|
||||||
if monthly.is_none() {
|
|
||||||
missing.push("enterprise_monthly_cop");
|
|
||||||
}
|
|
||||||
if reason.is_none_or(|value| value.trim().is_empty()) {
|
|
||||||
missing.push("enterprise_override_reason");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
missing
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_access_request(pool: &PgPool, id: Uuid) -> Result<AccessRequestRow, AppError> {
|
async fn fetch_access_request(pool: &PgPool, id: Uuid) -> Result<AccessRequestRow, AppError> {
|
||||||
let query = format!("{} WHERE ar.id = $1", ACCESS_REQUEST_SELECT);
|
let query = format!("{} WHERE ar.id = $1", ACCESS_REQUEST_SELECT);
|
||||||
|
|
||||||
@@ -411,169 +166,6 @@ async fn fetch_access_request(pool: &PgPool, id: Uuid) -> Result<AccessRequestRo
|
|||||||
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))
|
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commercial_profile_select() -> &'static str {
|
|
||||||
r#"SELECT id,
|
|
||||||
access_request_id,
|
|
||||||
project_id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
credit_limit_cop,
|
|
||||||
tg_limit::float8 AS tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k::float8 AS discount_k,
|
|
||||||
currency,
|
|
||||||
effective_from,
|
|
||||||
effective_to,
|
|
||||||
approved_by,
|
|
||||||
approved_at,
|
|
||||||
enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason,
|
|
||||||
notes,
|
|
||||||
(
|
|
||||||
plan IS NOT NULL
|
|
||||||
AND payment_method IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan = 'free'
|
|
||||||
OR (
|
|
||||||
payment_terms_days IS NOT NULL
|
|
||||||
AND tg_limit IS NOT NULL
|
|
||||||
AND effective_from IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan != 'enterprise'
|
|
||||||
OR (
|
|
||||||
enterprise_annual_cop IS NOT NULL
|
|
||||||
AND enterprise_monthly_cop IS NOT NULL
|
|
||||||
AND NULLIF(BTRIM(COALESCE(enterprise_override_reason, '')), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) AS profile_complete,
|
|
||||||
ARRAY_REMOVE(ARRAY[
|
|
||||||
CASE WHEN plan IS NULL THEN 'plan' END,
|
|
||||||
CASE WHEN payment_method IS NULL THEN 'payment_method' END,
|
|
||||||
CASE WHEN plan != 'free' AND payment_terms_days IS NULL THEN 'payment_terms_days' END,
|
|
||||||
CASE WHEN plan != 'free' AND tg_limit IS NULL THEN 'tg_limit' END,
|
|
||||||
CASE WHEN plan != 'free' AND effective_from IS NULL THEN 'effective_from' END,
|
|
||||||
CASE WHEN plan = 'enterprise' AND enterprise_annual_cop IS NULL THEN 'enterprise_annual_cop' END,
|
|
||||||
CASE WHEN plan = 'enterprise' AND enterprise_monthly_cop IS NULL THEN 'enterprise_monthly_cop' END,
|
|
||||||
CASE WHEN plan = 'enterprise' AND NULLIF(BTRIM(COALESCE(enterprise_override_reason, '')), '') IS NULL THEN 'enterprise_override_reason' END
|
|
||||||
], NULL)::TEXT[] AS missing_fields,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
FROM customer_commercial_profiles"#
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_commercial_profile_by_request(
|
|
||||||
pool: &PgPool,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<CommercialProfileRow, AppError> {
|
|
||||||
let query = format!(
|
|
||||||
"{} WHERE access_request_id = $1 ORDER BY updated_at DESC LIMIT 1",
|
|
||||||
commercial_profile_select()
|
|
||||||
);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, CommercialProfileRow>(&query)
|
|
||||||
.bind(request_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Perfil comercial".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_commercial_profile_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
profile_id: Uuid,
|
|
||||||
) -> Result<CommercialProfileRow, AppError> {
|
|
||||||
let query = format!("{} WHERE id = $1", commercial_profile_select());
|
|
||||||
|
|
||||||
sqlx::query_as::<_, CommercialProfileRow>(&query)
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Perfil comercial".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_classification_payload(
|
|
||||||
payload: &CommercialClassificationPayload,
|
|
||||||
) -> Result<Vec<&'static str>, AppError> {
|
|
||||||
let plan = payload.plan.as_deref().map(str::trim);
|
|
||||||
if let Some(plan) = plan {
|
|
||||||
if !matches!(plan, "free" | "operative" | "enterprise") {
|
|
||||||
return Err(AppError::BadRequest("Plan comercial inválido".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(payment_method) = payload.payment_method.as_deref().map(str::trim) {
|
|
||||||
if !matches!(
|
|
||||||
payment_method,
|
|
||||||
"none" | "bank_transfer" | "manual_invoice" | "enterprise_contract"
|
|
||||||
) {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Método de pago comercial inválido".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.reason.trim().len() < 3 {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Motivo de clasificación requerido".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(classification_missing_fields(
|
|
||||||
plan,
|
|
||||||
payload.payment_method.as_deref().map(str::trim),
|
|
||||||
payload.payment_terms_days,
|
|
||||||
payload.tg_limit,
|
|
||||||
payload.pbase_cop,
|
|
||||||
payload.discount_k,
|
|
||||||
payload.effective_from,
|
|
||||||
Some((
|
|
||||||
payload.enterprise_annual_cop,
|
|
||||||
payload.enterprise_monthly_cop,
|
|
||||||
payload.enterprise_override_reason.as_deref(),
|
|
||||||
)),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classification_persistence_intent(
|
|
||||||
payload: &CommercialClassificationPayload,
|
|
||||||
actor_user_id: Uuid,
|
|
||||||
) -> Result<ClassificationPersistenceIntent, AppError> {
|
|
||||||
let missing_fields = validate_classification_payload(payload)?;
|
|
||||||
let approved = missing_fields.is_empty();
|
|
||||||
let effective_from = payload
|
|
||||||
.effective_from
|
|
||||||
.unwrap_or_else(|| Utc::now().date_naive());
|
|
||||||
|
|
||||||
Ok(ClassificationPersistenceIntent {
|
|
||||||
missing_fields,
|
|
||||||
profile_status: if approved { "approved" } else { "in_review" },
|
|
||||||
request_status: if approved {
|
|
||||||
"approved"
|
|
||||||
} else {
|
|
||||||
"pending_classification"
|
|
||||||
},
|
|
||||||
approved_by: approved.then_some(actor_user_id),
|
|
||||||
approved_at_should_be_set: approved,
|
|
||||||
effective_from,
|
|
||||||
change_reason: payload.reason.trim().to_string(),
|
|
||||||
changed_fields: vec!["classification", "terms"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn classification_persistence_statements() -> ClassificationPersistenceStatements {
|
|
||||||
ClassificationPersistenceStatements {
|
|
||||||
profile_upsert: CLASSIFICATION_PROFILE_UPSERT_SQL,
|
|
||||||
version_insert: COMMERCIAL_PROFILE_VERSION_INSERT_SQL,
|
|
||||||
request_update: CLASSIFICATION_REQUEST_STATUS_UPDATE_SQL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn project_name_base(company: &str) -> String {
|
fn project_name_base(company: &str) -> String {
|
||||||
let sanitized: String = company
|
let sanitized: String = company
|
||||||
.chars()
|
.chars()
|
||||||
@@ -801,138 +393,6 @@ pub async fn get_access_request(
|
|||||||
Ok(Json(row))
|
Ok(Json(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/platform/commercial-profiles/:id
|
|
||||||
pub async fn get_commercial_profile(
|
|
||||||
_platform: PlatformClaims,
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<CommercialProfileRow>, AppError> {
|
|
||||||
Ok(Json(fetch_commercial_profile_by_id(&pool, id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/platform/access-requests/:id/classification
|
|
||||||
pub async fn classify_access_request(
|
|
||||||
platform: PlatformAdminClaims,
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(payload): Json<CommercialClassificationPayload>,
|
|
||||||
) -> Result<Json<CommercialProfileRow>, AppError> {
|
|
||||||
crate::auth::require_access_request_reviewer(&platform.0)?;
|
|
||||||
|
|
||||||
let platform_user_id = Uuid::parse_str(&platform.0.sub)
|
|
||||||
.map_err(|_| AppError::BadRequest("ID de operador inválido".to_string()))?;
|
|
||||||
let persistence_intent = classification_persistence_intent(&payload, platform_user_id)?;
|
|
||||||
let classification_complete = persistence_intent.missing_fields.is_empty();
|
|
||||||
debug_assert_eq!(
|
|
||||||
classification_complete,
|
|
||||||
persistence_intent.approved_at_should_be_set
|
|
||||||
);
|
|
||||||
debug_assert_eq!(
|
|
||||||
classification_complete,
|
|
||||||
persistence_intent.request_status == "approved"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
let request_status: String = sqlx::query_scalar(
|
|
||||||
r#"SELECT status
|
|
||||||
FROM access_requests
|
|
||||||
WHERE id = $1
|
|
||||||
FOR UPDATE"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
|
||||||
|
|
||||||
if request_status == "rejected" {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"No se puede clasificar una solicitud rechazada".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let before_json: serde_json::Value = sqlx::query_scalar(
|
|
||||||
r#"SELECT COALESCE(to_jsonb(ccp), '{}'::jsonb)
|
|
||||||
FROM customer_commercial_profiles ccp
|
|
||||||
WHERE ccp.access_request_id = $1
|
|
||||||
ORDER BY ccp.updated_at DESC
|
|
||||||
LIMIT 1"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.unwrap_or_else(|| serde_json::json!({}));
|
|
||||||
|
|
||||||
let profile_id: Uuid = sqlx::query_scalar(CLASSIFICATION_PROFILE_UPSERT_SQL)
|
|
||||||
.bind(id)
|
|
||||||
.bind(persistence_intent.profile_status)
|
|
||||||
.bind(payload.plan.as_deref().map(str::trim))
|
|
||||||
.bind(payload.payment_method.as_deref().map(str::trim))
|
|
||||||
.bind(payload.payment_terms_days)
|
|
||||||
.bind(payload.credit_limit_cop)
|
|
||||||
.bind(payload.tg_limit)
|
|
||||||
.bind(payload.pbase_cop)
|
|
||||||
.bind(payload.discount_k)
|
|
||||||
.bind(Some(persistence_intent.effective_from))
|
|
||||||
.bind(classification_complete)
|
|
||||||
.bind(persistence_intent.approved_by)
|
|
||||||
.bind(payload.enterprise_annual_cop)
|
|
||||||
.bind(payload.enterprise_monthly_cop)
|
|
||||||
.bind(payload.enterprise_override_reason.as_deref().map(str::trim))
|
|
||||||
.bind(payload.notes.as_deref().map(str::trim))
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let after_json: serde_json::Value = sqlx::query_scalar(
|
|
||||||
r#"SELECT to_jsonb(ccp)
|
|
||||||
FROM customer_commercial_profiles ccp
|
|
||||||
WHERE ccp.id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let changed_fields: Vec<String> = persistence_intent
|
|
||||||
.changed_fields
|
|
||||||
.iter()
|
|
||||||
.map(|field| (*field).to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
sqlx::query(COMMERCIAL_PROFILE_VERSION_INSERT_SQL)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(platform_user_id)
|
|
||||||
.bind(&persistence_intent.change_reason)
|
|
||||||
.bind(changed_fields)
|
|
||||||
.bind(before_json.clone())
|
|
||||||
.bind(after_json.clone())
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(CLASSIFICATION_REQUEST_STATUS_UPDATE_SQL)
|
|
||||||
.bind(id)
|
|
||||||
.bind(classification_complete)
|
|
||||||
.bind(platform_user_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
crate::audit::log(
|
|
||||||
&pool,
|
|
||||||
crate::audit::AuditEntry::new("commercial_profile.classify")
|
|
||||||
.with_user(platform_user_id, &platform.0.email)
|
|
||||||
.with_resource(format!("commercial_profile:{profile_id}"))
|
|
||||||
.with_metadata(crate::audit::commercial_profile_change_metadata(
|
|
||||||
before_json,
|
|
||||||
after_json,
|
|
||||||
&persistence_intent.change_reason,
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(Json(fetch_commercial_profile_by_request(&pool, id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/platform/access-requests/:id/approve
|
/// POST /api/platform/access-requests/:id/approve
|
||||||
pub async fn approve_access_request(
|
pub async fn approve_access_request(
|
||||||
platform: PlatformAdminClaims,
|
platform: PlatformAdminClaims,
|
||||||
@@ -957,48 +417,12 @@ pub async fn approve_access_request(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Solicitud de acceso".to_string()))?;
|
||||||
|
|
||||||
if !request_can_be_provisioned(&request.status) {
|
if !matches!(request.status.as_str(), "pending" | "in_review") {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"La solicitud requiere clasificación comercial aprobada antes de provisionarse"
|
"La solicitud no existe o ya fue cerrada".to_string(),
|
||||||
.to_string(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile_id: Uuid = sqlx::query_scalar(
|
|
||||||
r#"SELECT id
|
|
||||||
FROM customer_commercial_profiles
|
|
||||||
WHERE access_request_id = $1
|
|
||||||
AND status = 'approved'
|
|
||||||
AND plan IS NOT NULL
|
|
||||||
AND payment_method IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan = 'free'
|
|
||||||
OR (
|
|
||||||
payment_terms_days IS NOT NULL
|
|
||||||
AND tg_limit IS NOT NULL
|
|
||||||
AND effective_from IS NOT NULL
|
|
||||||
AND (
|
|
||||||
plan != 'enterprise'
|
|
||||||
OR (
|
|
||||||
enterprise_annual_cop IS NOT NULL
|
|
||||||
AND enterprise_monthly_cop IS NOT NULL
|
|
||||||
AND NULLIF(BTRIM(COALESCE(enterprise_override_reason, '')), '') IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT 1"#,
|
|
||||||
)
|
|
||||||
.bind(request.id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::BadRequest(
|
|
||||||
"La solicitud requiere perfil comercial aprobado antes de provisionarse".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let base_project_name = project_name_base(&request.company);
|
let base_project_name = project_name_base(&request.company);
|
||||||
let project_name_exists: bool =
|
let project_name_exists: bool =
|
||||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
|
||||||
@@ -1135,18 +559,6 @@ pub async fn approve_access_request(
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"UPDATE customer_commercial_profiles
|
|
||||||
SET project_id = $2,
|
|
||||||
status = 'active',
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
let email = if let Some(invitation) = &invitation {
|
let email = if let Some(invitation) = &invitation {
|
||||||
@@ -1796,9 +1208,8 @@ pub async fn mark_access_request_in_review(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ACCESS_REQUEST_SELECT, ApprovalRequestRow, ResendDeliveryKind,
|
ACCESS_REQUEST_SELECT, ApprovalRequestRow, ResendDeliveryKind,
|
||||||
approval_provisioning_audit_actions, classification_missing_fields,
|
approval_provisioning_audit_actions, invitation_email_delivery_update, project_defaults,
|
||||||
invitation_email_delivery_update, project_defaults, provisioning_audit_metadata,
|
provisioning_audit_metadata, resend_delivery_kind, should_revoke_invitation_on_resend,
|
||||||
request_can_be_provisioned, resend_delivery_kind, should_revoke_invitation_on_resend,
|
|
||||||
};
|
};
|
||||||
use crate::auth::{PlatformAdminClaims, PlatformClaims};
|
use crate::auth::{PlatformAdminClaims, PlatformClaims};
|
||||||
use axum::{Json, extract::Path, extract::State};
|
use axum::{Json, extract::Path, extract::State};
|
||||||
@@ -2008,174 +1419,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(decision, ResendDeliveryKind::ActivationInvitation);
|
assert_eq!(decision, ResendDeliveryKind::ActivationInvitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_classification_requires_terms_limits_and_effective_date_for_operative() {
|
|
||||||
let missing = classification_missing_fields(None, None, None, None, None, None, None, None);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
missing,
|
|
||||||
[
|
|
||||||
"plan",
|
|
||||||
"payment_method",
|
|
||||||
"payment_terms_days",
|
|
||||||
"tg_limit",
|
|
||||||
"effective_from"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
let complete = classification_missing_fields(
|
|
||||||
Some("operative"),
|
|
||||||
Some("bank_transfer"),
|
|
||||||
Some(30),
|
|
||||||
Some(10.0),
|
|
||||||
Some(110_000),
|
|
||||||
Some(0.1),
|
|
||||||
Some(chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap()),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(complete.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_free_classification_is_complete_with_explicit_no_charge_defaults() {
|
|
||||||
let missing = classification_missing_fields(
|
|
||||||
Some("free"),
|
|
||||||
Some("none"),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(110_000),
|
|
||||||
Some(0.1),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_free_classification_persistence_intent_approves_without_billing_terms() {
|
|
||||||
let actor_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap();
|
|
||||||
let payload = super::CommercialClassificationPayload {
|
|
||||||
plan: Some("free".to_string()),
|
|
||||||
payment_method: Some("none".to_string()),
|
|
||||||
payment_terms_days: None,
|
|
||||||
credit_limit_cop: None,
|
|
||||||
tg_limit: None,
|
|
||||||
pbase_cop: Some(110_000),
|
|
||||||
discount_k: Some(0.1),
|
|
||||||
effective_from: None,
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
notes: Some("Free non-collecting access".to_string()),
|
|
||||||
reason: "Clasificacion Free".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let intent = super::classification_persistence_intent(&payload, actor_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(intent.profile_status, "approved");
|
|
||||||
assert_eq!(intent.request_status, "approved");
|
|
||||||
assert_eq!(intent.approved_by, Some(actor_id));
|
|
||||||
assert!(intent.approved_at_should_be_set);
|
|
||||||
assert!(intent.missing_fields.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enterprise_classification_requires_override_values_and_reason() {
|
|
||||||
let missing = classification_missing_fields(
|
|
||||||
Some("enterprise"),
|
|
||||||
Some("enterprise_contract"),
|
|
||||||
Some(45),
|
|
||||||
Some(100.0),
|
|
||||||
Some(110_000),
|
|
||||||
Some(0.1),
|
|
||||||
Some(chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap()),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
missing,
|
|
||||||
[
|
|
||||||
"enterprise_annual_cop",
|
|
||||||
"enterprise_monthly_cop",
|
|
||||||
"enterprise_override_reason"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn approval_is_blocked_until_profile_is_commercially_approved() {
|
|
||||||
assert!(!request_can_be_provisioned("pending"));
|
|
||||||
assert!(!request_can_be_provisioned("in_review"));
|
|
||||||
assert!(!request_can_be_provisioned("pending_classification"));
|
|
||||||
assert!(request_can_be_provisioned("approved"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_classification_persistence_intent_records_effective_date_approver_and_version_shape()
|
|
||||||
{
|
|
||||||
let actor_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap();
|
|
||||||
let effective_from = chrono::NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
|
||||||
let payload = super::CommercialClassificationPayload {
|
|
||||||
plan: Some("operative".to_string()),
|
|
||||||
payment_method: Some("manual_invoice".to_string()),
|
|
||||||
payment_terms_days: Some(30),
|
|
||||||
credit_limit_cop: Some(25_000_000),
|
|
||||||
tg_limit: Some(75.0),
|
|
||||||
pbase_cop: Some(110_000),
|
|
||||||
discount_k: Some(0.1),
|
|
||||||
effective_from: Some(effective_from),
|
|
||||||
enterprise_annual_cop: None,
|
|
||||||
enterprise_monthly_cop: None,
|
|
||||||
enterprise_override_reason: None,
|
|
||||||
notes: Some("Approved commercial terms".to_string()),
|
|
||||||
reason: "Terms approved for July rollout".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let intent = super::classification_persistence_intent(&payload, actor_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(intent.profile_status, "approved");
|
|
||||||
assert_eq!(intent.request_status, "approved");
|
|
||||||
assert_eq!(intent.approved_by, Some(actor_id));
|
|
||||||
assert!(intent.approved_at_should_be_set);
|
|
||||||
assert_eq!(intent.effective_from, effective_from);
|
|
||||||
assert_eq!(intent.change_reason, "Terms approved for July rollout");
|
|
||||||
assert_eq!(intent.changed_fields, ["classification", "terms"]);
|
|
||||||
assert!(intent.missing_fields.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_classification_sql_intent_persists_profile_upsert_version_and_access_request_status()
|
|
||||||
{
|
|
||||||
let statements = super::classification_persistence_statements();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
statements
|
|
||||||
.profile_upsert
|
|
||||||
.contains("INSERT INTO customer_commercial_profiles")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
statements
|
|
||||||
.profile_upsert
|
|
||||||
.contains("ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL")
|
|
||||||
);
|
|
||||||
assert!(statements.profile_upsert.contains("effective_from"));
|
|
||||||
assert!(statements.profile_upsert.contains("approved_by"));
|
|
||||||
assert!(statements.profile_upsert.contains("approved_at"));
|
|
||||||
assert!(
|
|
||||||
statements
|
|
||||||
.version_insert
|
|
||||||
.contains("INSERT INTO customer_commercial_profile_versions")
|
|
||||||
);
|
|
||||||
assert!(statements.version_insert.contains("actor_user_id"));
|
|
||||||
assert!(statements.version_insert.contains("change_reason"));
|
|
||||||
assert!(statements.version_insert.contains("changed_fields"));
|
|
||||||
assert!(statements.version_insert.contains("before_json"));
|
|
||||||
assert!(statements.version_insert.contains("after_json"));
|
|
||||||
assert!(statements.request_update.contains("pending_classification"));
|
|
||||||
assert!(statements.request_update.contains("reviewed_by"));
|
|
||||||
assert!(statements.request_update.contains("reviewed_at"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,14 +105,6 @@ struct AccessRequestResponse {
|
|||||||
message: &'static str,
|
message: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUEST_ACCESS_DRAFT_PROFILE_SQL: &str = r#"INSERT INTO customer_commercial_profiles (access_request_id, status, plan, payment_method, effective_from, notes)
|
|
||||||
VALUES ($1, 'draft', 'free', 'none', CURRENT_DATE, 'Created from public access request; pending commercial classification.')
|
|
||||||
ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL DO NOTHING"#;
|
|
||||||
|
|
||||||
const REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL: &str = r#"UPDATE access_requests
|
|
||||||
SET status = 'pending_classification', updated_at = NOW()
|
|
||||||
WHERE id = $1 AND status = 'pending'"#;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct UserRow {
|
struct UserRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
@@ -460,7 +452,7 @@ pub async fn request_access(
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM access_requests
|
FROM access_requests
|
||||||
WHERE lower(email) = $1
|
WHERE lower(email) = $1
|
||||||
AND status IN ('pending', 'pending_classification', 'in_review', 'approved')
|
AND status IN ('pending', 'in_review', 'approved')
|
||||||
)"#,
|
)"#,
|
||||||
)
|
)
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
@@ -475,7 +467,7 @@ pub async fn request_access(
|
|||||||
return Ok(duplicate_access_request_response());
|
return Ok(duplicate_access_request_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id_result: Result<uuid::Uuid, sqlx::Error> = sqlx::query_scalar(
|
let request_id = sqlx::query_scalar(
|
||||||
r#"INSERT INTO access_requests
|
r#"INSERT INTO access_requests
|
||||||
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
|
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
@@ -500,7 +492,7 @@ pub async fn request_access(
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let request_id = match request_id_result {
|
let request_id = match request_id {
|
||||||
Ok(request_id) => request_id,
|
Ok(request_id) => request_id,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if is_active_access_request_unique_violation(&error) {
|
if is_active_access_request_unique_violation(&error) {
|
||||||
@@ -525,29 +517,42 @@ pub async fn request_access(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
sqlx::query(REQUEST_ACCESS_DRAFT_PROFILE_SQL)
|
let approved_request = if let Ok(row) =
|
||||||
.bind(request_id)
|
crate::handlers::access_requests::auto_approve_access_request(&pool, request_id, &headers)
|
||||||
.execute(&pool)
|
.await
|
||||||
.await
|
{
|
||||||
.map_err(|error| {
|
row
|
||||||
tracing::error!(request_id = %request_id, error = %error, "Commercial profile creation failed");
|
} else {
|
||||||
AuthError::Internal(anyhow::anyhow!("Error al registrar perfil comercial"))
|
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)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
tracing::error!(request_id = %request_id, "Access request auto-approval failed");
|
||||||
|
return Err(AuthError::Internal(anyhow::anyhow!(
|
||||||
|
"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)
|
StatusCode::ACCEPTED,
|
||||||
.await
|
Json(AccessRequestResponse {
|
||||||
.map_err(|error| {
|
status: "approved_email_failed",
|
||||||
tracing::error!(request_id = %request_id, error = %error, "Access request classification status update failed");
|
message: "Tu prueba gratuita fue aprobada, pero no pudimos enviar el correo de activación. Escríbenos a soporte@omnioil.app.",
|
||||||
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud"))
|
}),
|
||||||
})?;
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::CREATED,
|
||||||
Json(AccessRequestResponse {
|
Json(AccessRequestResponse {
|
||||||
status: "pending_classification",
|
status: "approved",
|
||||||
message: "Recibimos tu solicitud. El equipo comercial la revisará antes de activar el acceso.",
|
message: "Tu prueba gratuita fue aprobada. Revisa tu correo para activar la cuenta.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
@@ -976,34 +981,6 @@ pub async fn get_setup_status(State(pool): State<DbPool>) -> Result<Json<SetupSt
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{REQUEST_ACCESS_DRAFT_PROFILE_SQL, REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commercial_request_access_persists_draft_profile_before_pending_classification_response() {
|
|
||||||
assert!(
|
|
||||||
REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("INSERT INTO customer_commercial_profiles")
|
|
||||||
);
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("access_request_id"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'draft'"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'free'"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'none'"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("CURRENT_DATE"));
|
|
||||||
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains(
|
|
||||||
"ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL DO NOTHING"
|
|
||||||
));
|
|
||||||
assert!(
|
|
||||||
REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL
|
|
||||||
.contains("SET status = 'pending_classification'")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL
|
|
||||||
.contains("WHERE id = $1 AND status = 'pending'")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetupRegisterPayload {
|
pub struct SetupRegisterPayload {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ use std::collections::HashMap;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{Claims, PlatformAdminClaims};
|
use crate::auth::{Claims, PlatformAdminClaims};
|
||||||
use crate::billing_rules::{self, OperativeBillingInput};
|
|
||||||
|
|
||||||
const LEGACY_DEFAULT_PRICE_PER_TAG_COP: i64 = 90_000;
|
|
||||||
const OFFICIAL_PRICE_PER_TAG_COP: i64 = 110_000;
|
|
||||||
|
|
||||||
// ─── Error helper ───────────────────────────────────────────────────────────
|
// ─── Error helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -66,8 +62,6 @@ pub struct BillingUsage {
|
|||||||
pub project_name: String,
|
pub project_name: String,
|
||||||
pub tier: String,
|
pub tier: String,
|
||||||
pub subscription_status: String,
|
pub subscription_status: String,
|
||||||
pub hf_variables: i64,
|
|
||||||
pub lf_variables: i64,
|
|
||||||
pub active_tags: i64,
|
pub active_tags: i64,
|
||||||
pub tag_limit: i32,
|
pub tag_limit: i32,
|
||||||
pub price_per_tag: i64,
|
pub price_per_tag: i64,
|
||||||
@@ -79,47 +73,6 @@ pub struct BillingUsage {
|
|||||||
pub period_start: DateTime<Utc>,
|
pub period_start: DateTime<Utc>,
|
||||||
pub period_end: DateTime<Utc>,
|
pub period_end: DateTime<Utc>,
|
||||||
pub last_snapshot: Option<NaiveDate>,
|
pub last_snapshot: Option<NaiveDate>,
|
||||||
pub commercial_profile: Option<CommercialProfileBillingSummary>,
|
|
||||||
pub formula: Option<OperativeFormulaSummary>,
|
|
||||||
pub calculation_lines: Vec<BillingCalculationLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct CommercialProfileBillingSummary {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
pub plan: String,
|
|
||||||
pub payment_method: Option<String>,
|
|
||||||
pub payment_terms_days: Option<i32>,
|
|
||||||
pub credit_limit_cop: Option<i64>,
|
|
||||||
pub tg_limit: Option<f64>,
|
|
||||||
pub effective_from: NaiveDate,
|
|
||||||
pub profile_complete: bool,
|
|
||||||
pub missing_fields: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct OperativeFormulaSummary {
|
|
||||||
pub hf_tags: u32,
|
|
||||||
pub lf_tags: u32,
|
|
||||||
pub equivalent_tg: f64,
|
|
||||||
pub pbase_cop: i64,
|
|
||||||
pub k: f64,
|
|
||||||
pub annual_cop: i64,
|
|
||||||
pub monthly_cop: i64,
|
|
||||||
pub currency: String,
|
|
||||||
pub rounding_policy: String,
|
|
||||||
pub effective_from: NaiveDate,
|
|
||||||
pub effective_hf_price_cop: i64,
|
|
||||||
pub effective_lf_price_cop: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct BillingCalculationLine {
|
|
||||||
pub kind: String,
|
|
||||||
pub label: String,
|
|
||||||
pub amount_cop: i64,
|
|
||||||
pub source: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -150,50 +103,6 @@ struct ProjectLookup {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct BillingVariableCounts {
|
|
||||||
hf_variables: i64,
|
|
||||||
lf_variables: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CurrentBillingCalculation {
|
|
||||||
active_tags: i64,
|
|
||||||
price_per_tag: i64,
|
|
||||||
subtotal_annual: i64,
|
|
||||||
minimum_applied: bool,
|
|
||||||
total_annual_cop: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
|
||||||
struct CommercialProfileForBilling {
|
|
||||||
id: Uuid,
|
|
||||||
status: String,
|
|
||||||
plan: String,
|
|
||||||
payment_method: Option<String>,
|
|
||||||
payment_terms_days: Option<i32>,
|
|
||||||
credit_limit_cop: Option<i64>,
|
|
||||||
tg_limit: Option<f64>,
|
|
||||||
pbase_cop: i64,
|
|
||||||
discount_k: f64,
|
|
||||||
currency: String,
|
|
||||||
effective_from: NaiveDate,
|
|
||||||
enterprise_annual_cop: Option<i64>,
|
|
||||||
enterprise_monthly_cop: Option<i64>,
|
|
||||||
enterprise_override_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProfileBillingResponse {
|
|
||||||
active_tags: i64,
|
|
||||||
price_per_tag: i64,
|
|
||||||
subtotal_annual: i64,
|
|
||||||
minimum_applied: bool,
|
|
||||||
total_annual_cop: i64,
|
|
||||||
total_monthly_cop: i64,
|
|
||||||
commercial_profile: CommercialProfileBillingSummary,
|
|
||||||
formula: Option<OperativeFormulaSummary>,
|
|
||||||
calculation_lines: Vec<BillingCalculationLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateSubscriptionRequest {
|
pub struct UpdateSubscriptionRequest {
|
||||||
pub tier: Option<String>,
|
pub tier: Option<String>,
|
||||||
@@ -228,7 +137,7 @@ async fn get_or_create_subscription(
|
|||||||
// Auto-create starter subscription
|
// Auto-create starter subscription
|
||||||
sqlx::query_as::<_, Subscription>(
|
sqlx::query_as::<_, Subscription>(
|
||||||
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, price_per_tag, min_annual_fee, status)
|
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, price_per_tag, min_annual_fee, status)
|
||||||
VALUES ($1, 'starter', 50, 110000, 4000000, 'active')
|
VALUES ($1, 'starter', 50, 90000, 4000000, 'active')
|
||||||
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
|
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING *"#,
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
@@ -252,380 +161,6 @@ fn calculate_billing(
|
|||||||
(subtotal, minimum_applied, total)
|
(subtotal, minimum_applied, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn effective_price_per_tag(stored_price_per_tag: i64) -> i64 {
|
|
||||||
if stored_price_per_tag == LEGACY_DEFAULT_PRICE_PER_TAG_COP {
|
|
||||||
OFFICIAL_PRICE_PER_TAG_COP
|
|
||||||
} else {
|
|
||||||
stored_price_per_tag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_current_billing(
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
stored_price_per_tag: i64,
|
|
||||||
min_annual_fee: i64,
|
|
||||||
) -> CurrentBillingCalculation {
|
|
||||||
let active_tags = counts.hf_variables + counts.lf_variables;
|
|
||||||
let price_per_tag = effective_price_per_tag(stored_price_per_tag);
|
|
||||||
let (subtotal_annual, minimum_applied, total_annual_cop) =
|
|
||||||
calculate_billing(active_tags, price_per_tag, min_annual_fee);
|
|
||||||
|
|
||||||
CurrentBillingCalculation {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag,
|
|
||||||
subtotal_annual,
|
|
||||||
minimum_applied,
|
|
||||||
total_annual_cop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_billing_usage_response(
|
|
||||||
project_id: Uuid,
|
|
||||||
project_name: String,
|
|
||||||
sub: &Subscription,
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
billing: CurrentBillingCalculation,
|
|
||||||
last_snapshot: Option<NaiveDate>,
|
|
||||||
) -> BillingUsage {
|
|
||||||
BillingUsage {
|
|
||||||
project_id,
|
|
||||||
project_name,
|
|
||||||
tier: sub.tier.clone(),
|
|
||||||
subscription_status: sub.status.clone(),
|
|
||||||
hf_variables: counts.hf_variables,
|
|
||||||
lf_variables: counts.lf_variables,
|
|
||||||
active_tags: billing.active_tags,
|
|
||||||
tag_limit: sub.tag_limit,
|
|
||||||
price_per_tag: billing.price_per_tag,
|
|
||||||
min_annual_fee: sub.min_annual_fee,
|
|
||||||
subtotal_annual: billing.subtotal_annual,
|
|
||||||
minimum_applied: billing.minimum_applied,
|
|
||||||
total_annual_cop: billing.total_annual_cop,
|
|
||||||
total_monthly_cop: billing.total_annual_cop / 12,
|
|
||||||
period_start: sub.current_period_start,
|
|
||||||
period_end: sub.current_period_end,
|
|
||||||
last_snapshot,
|
|
||||||
commercial_profile: None,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "legacy_subscription".to_string(),
|
|
||||||
label: "Legacy subscription tag billing".to_string(),
|
|
||||||
amount_cop: billing.total_annual_cop,
|
|
||||||
source: Some("subscriptions".to_string()),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn profile_missing_fields(profile: &CommercialProfileForBilling) -> Vec<String> {
|
|
||||||
let mut missing = Vec::new();
|
|
||||||
|
|
||||||
if profile.payment_method.is_none() {
|
|
||||||
missing.push("payment_method".to_string());
|
|
||||||
}
|
|
||||||
if profile.plan != "free" {
|
|
||||||
if profile.payment_terms_days.is_none() {
|
|
||||||
missing.push("payment_terms_days".to_string());
|
|
||||||
}
|
|
||||||
if profile.tg_limit.is_none() {
|
|
||||||
missing.push("tg_limit".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.plan == "enterprise" {
|
|
||||||
if profile.enterprise_annual_cop.is_none() {
|
|
||||||
missing.push("enterprise_annual_cop".to_string());
|
|
||||||
}
|
|
||||||
if profile.enterprise_monthly_cop.is_none() {
|
|
||||||
missing.push("enterprise_monthly_cop".to_string());
|
|
||||||
}
|
|
||||||
if profile
|
|
||||||
.enterprise_override_reason
|
|
||||||
.as_deref()
|
|
||||||
.is_none_or(|reason| reason.trim().is_empty())
|
|
||||||
{
|
|
||||||
missing.push("enterprise_override_reason".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
missing
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commercial_profile_summary(
|
|
||||||
profile: &CommercialProfileForBilling,
|
|
||||||
missing_fields: Vec<String>,
|
|
||||||
) -> CommercialProfileBillingSummary {
|
|
||||||
CommercialProfileBillingSummary {
|
|
||||||
id: profile.id,
|
|
||||||
status: profile.status.clone(),
|
|
||||||
plan: profile.plan.clone(),
|
|
||||||
payment_method: profile.payment_method.clone(),
|
|
||||||
payment_terms_days: profile.payment_terms_days,
|
|
||||||
credit_limit_cop: profile.credit_limit_cop,
|
|
||||||
tg_limit: profile.tg_limit,
|
|
||||||
effective_from: profile.effective_from,
|
|
||||||
profile_complete: missing_fields.is_empty()
|
|
||||||
&& matches!(profile.status.as_str(), "approved" | "active"),
|
|
||||||
missing_fields,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_profile_billing_response(
|
|
||||||
profile: &CommercialProfileForBilling,
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
) -> Result<ProfileBillingResponse, billing_rules::BillingRulesError> {
|
|
||||||
let missing_fields = profile_missing_fields(profile);
|
|
||||||
let commercial_profile = commercial_profile_summary(profile, missing_fields);
|
|
||||||
let active_tags = counts.hf_variables + counts.lf_variables;
|
|
||||||
|
|
||||||
if !commercial_profile.profile_complete {
|
|
||||||
return Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
commercial_profile,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "incomplete_profile".to_string(),
|
|
||||||
label: "Incomplete commercial profile is non-billable".to_string(),
|
|
||||||
amount_cop: 0,
|
|
||||||
source: Some("customer_commercial_profiles".to_string()),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match profile.plan.as_str() {
|
|
||||||
"free" => Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
commercial_profile,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "free_non_collecting".to_string(),
|
|
||||||
label: "Free plan does not collect payment".to_string(),
|
|
||||||
amount_cop: 0,
|
|
||||||
source: Some("customer_commercial_profiles".to_string()),
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
"enterprise" => {
|
|
||||||
let annual = profile.enterprise_annual_cop.unwrap_or(0);
|
|
||||||
let monthly = profile.enterprise_monthly_cop.unwrap_or(0);
|
|
||||||
Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: 0,
|
|
||||||
subtotal_annual: annual,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: annual,
|
|
||||||
total_monthly_cop: monthly,
|
|
||||||
commercial_profile,
|
|
||||||
formula: None,
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "enterprise_override".to_string(),
|
|
||||||
label: "Enterprise override pricing".to_string(),
|
|
||||||
amount_cop: annual,
|
|
||||||
source: profile.enterprise_override_reason.clone(),
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let breakdown = billing_rules::calculate_operative_billing(OperativeBillingInput {
|
|
||||||
hf_tags: counts.hf_variables.max(0) as u32,
|
|
||||||
lf_tags: counts.lf_variables.max(0) as u32,
|
|
||||||
pbase_cop: profile.pbase_cop,
|
|
||||||
k: profile.discount_k,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(ProfileBillingResponse {
|
|
||||||
active_tags,
|
|
||||||
price_per_tag: breakdown.effective_hf_price_cop,
|
|
||||||
subtotal_annual: breakdown.annual_cop,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: breakdown.annual_cop,
|
|
||||||
total_monthly_cop: breakdown.monthly_cop,
|
|
||||||
commercial_profile,
|
|
||||||
formula: Some(OperativeFormulaSummary {
|
|
||||||
hf_tags: breakdown.hf_tags,
|
|
||||||
lf_tags: breakdown.lf_tags,
|
|
||||||
equivalent_tg: breakdown.equivalent_tg,
|
|
||||||
pbase_cop: breakdown.pbase_cop,
|
|
||||||
k: breakdown.k,
|
|
||||||
annual_cop: breakdown.annual_cop,
|
|
||||||
monthly_cop: breakdown.monthly_cop,
|
|
||||||
currency: profile.currency.clone(),
|
|
||||||
rounding_policy: breakdown.rounding_policy.to_string(),
|
|
||||||
effective_from: profile.effective_from,
|
|
||||||
effective_hf_price_cop: breakdown.effective_hf_price_cop,
|
|
||||||
effective_lf_price_cop: breakdown.effective_lf_price_cop,
|
|
||||||
}),
|
|
||||||
calculation_lines: vec![BillingCalculationLine {
|
|
||||||
kind: "operative_formula".to_string(),
|
|
||||||
label: "V = HF + (LF * 0.4); annual = V * Pbase * V^-k".to_string(),
|
|
||||||
amount_cop: breakdown.annual_cop,
|
|
||||||
source: Some("billing_rules".to_string()),
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_profile_billing_usage_response(
|
|
||||||
project_id: Uuid,
|
|
||||||
project_name: String,
|
|
||||||
sub: &Subscription,
|
|
||||||
counts: BillingVariableCounts,
|
|
||||||
billing: ProfileBillingResponse,
|
|
||||||
last_snapshot: Option<NaiveDate>,
|
|
||||||
) -> BillingUsage {
|
|
||||||
BillingUsage {
|
|
||||||
project_id,
|
|
||||||
project_name,
|
|
||||||
tier: billing.commercial_profile.plan.clone(),
|
|
||||||
subscription_status: sub.status.clone(),
|
|
||||||
hf_variables: counts.hf_variables,
|
|
||||||
lf_variables: counts.lf_variables,
|
|
||||||
active_tags: billing.active_tags,
|
|
||||||
tag_limit: sub.tag_limit,
|
|
||||||
price_per_tag: billing.price_per_tag,
|
|
||||||
min_annual_fee: 0,
|
|
||||||
subtotal_annual: billing.subtotal_annual,
|
|
||||||
minimum_applied: billing.minimum_applied,
|
|
||||||
total_annual_cop: billing.total_annual_cop,
|
|
||||||
total_monthly_cop: billing.total_monthly_cop,
|
|
||||||
period_start: sub.current_period_start,
|
|
||||||
period_end: sub.current_period_end,
|
|
||||||
last_snapshot,
|
|
||||||
commercial_profile: Some(billing.commercial_profile),
|
|
||||||
formula: billing.formula,
|
|
||||||
calculation_lines: billing.calculation_lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hf_variables_count_sql() -> &'static str {
|
|
||||||
r#"SELECT COUNT(*)
|
|
||||||
FROM edge_variables ev
|
|
||||||
JOIN edge_devices ed ON ev.device_id = ed.id
|
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
|
||||||
WHERE ea.project_id = $1
|
|
||||||
AND ev.is_enabled = true
|
|
||||||
AND ed.is_enabled = true
|
|
||||||
AND ea.is_active = true"#
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lf_variables_count_sql() -> &'static str {
|
|
||||||
r#"SELECT COUNT(DISTINCT lower(field_name))
|
|
||||||
FROM telemetry_raw tr
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT field_name, field_value
|
|
||||||
FROM jsonb_each(tr.data) AS top_level(field_name, field_value)
|
|
||||||
UNION ALL
|
|
||||||
SELECT field_name, field_value
|
|
||||||
-- Expands values telemetry shape: jsonb_each(tr.data -> 'values')
|
|
||||||
FROM jsonb_each(
|
|
||||||
CASE
|
|
||||||
WHEN jsonb_typeof(tr.data -> 'values') = 'object' THEN tr.data -> 'values'
|
|
||||||
ELSE '{}'::jsonb
|
|
||||||
END
|
|
||||||
) AS values_fields(field_name, field_value)
|
|
||||||
UNION ALL
|
|
||||||
SELECT field_name, field_value
|
|
||||||
-- Expands manual capture shape: jsonb_each(tr.data -> 'manual_fields')
|
|
||||||
FROM jsonb_each(
|
|
||||||
CASE
|
|
||||||
WHEN jsonb_typeof(tr.data -> 'manual_fields') = 'object' THEN tr.data -> 'manual_fields'
|
|
||||||
ELSE '{}'::jsonb
|
|
||||||
END
|
|
||||||
) AS manual_fields(field_name, field_value)
|
|
||||||
) fields
|
|
||||||
WHERE tr.project_id = $1
|
|
||||||
AND tr.source IN ('PWA', 'MANUAL')
|
|
||||||
AND jsonb_typeof(field_value) = 'number'
|
|
||||||
AND lower(field_name) NOT IN (
|
|
||||||
'project_id', 'device_name', 'timestamp', 'asset_id', 'asset',
|
|
||||||
'last_updated', 'telemetry', 'time', 'data', 'id', 'source',
|
|
||||||
'frequency_class', 'created_at', 'updated_at', 'tipo',
|
|
||||||
'observaciones', 'values', 'variables', 'manual_fields', 'client_id'
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM edge_variables ev
|
|
||||||
JOIN edge_devices ed ON ev.device_id = ed.id
|
|
||||||
JOIN edge_assets ea ON ed.asset_id = ea.id
|
|
||||||
WHERE ea.project_id = tr.project_id
|
|
||||||
AND ev.is_enabled = true
|
|
||||||
AND ed.is_enabled = true
|
|
||||||
AND ea.is_active = true
|
|
||||||
AND lower(ev.alias) = lower(field_name)
|
|
||||||
)"#
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_hf_variables(pool: &PgPool, project_id: Uuid) -> Result<i64, sqlx::Error> {
|
|
||||||
sqlx::query_scalar::<_, i64>(hf_variables_count_sql())
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_lf_variables(pool: &PgPool, project_id: Uuid) -> Result<i64, sqlx::Error> {
|
|
||||||
sqlx::query_scalar::<_, i64>(lf_variables_count_sql())
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_billing_variables(
|
|
||||||
pool: &PgPool,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<BillingVariableCounts, sqlx::Error> {
|
|
||||||
let hf_variables = count_hf_variables(pool, project_id).await?;
|
|
||||||
let lf_variables = count_lf_variables(pool, project_id).await?;
|
|
||||||
|
|
||||||
Ok(BillingVariableCounts {
|
|
||||||
hf_variables,
|
|
||||||
lf_variables,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_commercial_profile_for_billing(
|
|
||||||
pool: &PgPool,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<Option<CommercialProfileForBilling>, sqlx::Error> {
|
|
||||||
sqlx::query_as::<_, CommercialProfileForBilling>(
|
|
||||||
r#"SELECT id,
|
|
||||||
status,
|
|
||||||
plan,
|
|
||||||
payment_method,
|
|
||||||
payment_terms_days,
|
|
||||||
credit_limit_cop,
|
|
||||||
tg_limit::float8 AS tg_limit,
|
|
||||||
pbase_cop,
|
|
||||||
discount_k::float8 AS discount_k,
|
|
||||||
currency,
|
|
||||||
effective_from,
|
|
||||||
enterprise_annual_cop,
|
|
||||||
enterprise_monthly_cop,
|
|
||||||
enterprise_override_reason
|
|
||||||
FROM customer_commercial_profiles
|
|
||||||
WHERE project_id = $1
|
|
||||||
AND status IN ('approved', 'active', 'in_review', 'draft', 'suspended')
|
|
||||||
ORDER BY CASE status
|
|
||||||
WHEN 'active' THEN 0
|
|
||||||
WHEN 'approved' THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END,
|
|
||||||
effective_from DESC,
|
|
||||||
updated_at DESC
|
|
||||||
LIMIT 1"#,
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/billing/usage?project_id=UUID
|
/// GET /api/billing/usage?project_id=UUID
|
||||||
@@ -658,11 +193,21 @@ pub async fn get_billing_usage(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Count current billable variables from real persisted HF/LF sources.
|
// Count active tags
|
||||||
let counts = match count_billing_variables(&pool, project_id).await {
|
let tag_count: i64 = match sqlx::query_scalar(
|
||||||
Ok(counts) => counts,
|
r#"SELECT COUNT(*)
|
||||||
|
FROM edge_variables ev
|
||||||
|
JOIN edge_devices ed ON ev.device_id = ed.id
|
||||||
|
JOIN edge_assets ea ON ed.asset_id = ea.id
|
||||||
|
WHERE ea.project_id = $1 AND ev.is_enabled = true"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("DB error counting billing variables: {}", e);
|
tracing::error!("DB error counting tags: {}", e);
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -685,47 +230,26 @@ pub async fn get_billing_usage(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
|
|
||||||
let profile = match fetch_commercial_profile_for_billing(&pool, project_id).await {
|
let (subtotal, minimum_applied, total) =
|
||||||
Ok(profile) => profile,
|
calculate_billing(tag_count, sub.price_per_tag, sub.min_annual_fee);
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("DB error fetching commercial profile: {}", e);
|
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(profile) = profile {
|
Json(BillingUsage {
|
||||||
let billing = match build_profile_billing_response(&profile, counts) {
|
|
||||||
Ok(billing) => billing,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(?error, "Commercial billing calculation failed");
|
|
||||||
return err(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Billing calculation error",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Json(build_profile_billing_usage_response(
|
|
||||||
project_id,
|
|
||||||
project.name,
|
|
||||||
&sub,
|
|
||||||
counts,
|
|
||||||
billing,
|
|
||||||
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_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()
|
.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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ struct TelemetryRow {
|
|||||||
asset_code: String,
|
asset_code: String,
|
||||||
time: DateTime<Utc>,
|
time: DateTime<Utc>,
|
||||||
data: Value,
|
data: Value,
|
||||||
source: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
|
pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
|
||||||
@@ -23,8 +22,7 @@ pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl Int
|
|||||||
t.asset_id,
|
t.asset_id,
|
||||||
a.code as asset_code,
|
a.code as asset_code,
|
||||||
t.time,
|
t.time,
|
||||||
t.data,
|
t.data
|
||||||
t.source
|
|
||||||
FROM telemetry_raw t
|
FROM telemetry_raw t
|
||||||
JOIN edge_assets a ON t.asset_id = a.id
|
JOIN edge_assets a ON t.asset_id = a.id
|
||||||
JOIN user_project_access upa ON a.project_id = upa.project_id
|
JOIN user_project_access upa ON a.project_id = upa.project_id
|
||||||
@@ -45,7 +43,6 @@ pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl Int
|
|||||||
"asset_id": row.asset_id,
|
"asset_id": row.asset_id,
|
||||||
"asset": row.asset_code,
|
"asset": row.asset_code,
|
||||||
"last_updated": row.time,
|
"last_updated": row.time,
|
||||||
"source": row.source,
|
|
||||||
"telemetry": row.data
|
"telemetry": row.data
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::handlers::well_access;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -35,14 +34,22 @@ pub async fn list_downtime(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let access = sqlx::query(
|
||||||
&pool,
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
params.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para ver paradas de este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver paradas de este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
"SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC",
|
"SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC",
|
||||||
)
|
)
|
||||||
@@ -62,14 +69,22 @@ pub async fn create_downtime(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let access = sqlx::query(
|
||||||
&pool,
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
req.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para registrar paradas en este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para registrar paradas en este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
r#"INSERT INTO operations_downtime (
|
r#"INSERT INTO operations_downtime (
|
||||||
asset_id, start_time, end_time, is_planned, reason, comments
|
asset_id, start_time, end_time, is_planned, reason, comments
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ pub async fn trigger_ota_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve project_id from agent_id (agents are keyed by project_id)
|
// Resolve project_id from agent_id (agents are keyed by project_id)
|
||||||
let project_id: Uuid = sqlx::query_scalar(
|
let project_id: Uuid = sqlx::query_scalar("SELECT id FROM edge_projects WHERE id = $1")
|
||||||
"SELECT id FROM edge_projects WHERE id = $1"
|
.bind(agent_id)
|
||||||
)
|
.fetch_optional(&state.pool)
|
||||||
.bind(agent_id)
|
.await?
|
||||||
.fetch_optional(&state.pool)
|
.ok_or_else(|| {
|
||||||
.await?
|
crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id))
|
||||||
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)))?;
|
})?;
|
||||||
|
|
||||||
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
|
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
|
||||||
let target_version = match req.version.as_deref() {
|
let target_version = match req.version.as_deref() {
|
||||||
@@ -71,8 +71,8 @@ pub async fn trigger_ota_update(
|
|||||||
|
|
||||||
// Build the download URL pointing to this backend's proxy endpoint.
|
// Build the download URL pointing to this backend's proxy endpoint.
|
||||||
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
|
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
|
||||||
let api_base = std::env::var("API_BASE_URL")
|
let api_base =
|
||||||
.unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
||||||
let download_url = format!(
|
let download_url = format!(
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}",
|
"{}/api/edge/download-agent?version={}&asset_type={}",
|
||||||
api_base.trim_end_matches('/'),
|
api_base.trim_end_matches('/'),
|
||||||
@@ -94,13 +94,17 @@ pub async fn trigger_ota_update(
|
|||||||
"issued_at": chrono::Utc::now().to_rfc3339(),
|
"issued_at": chrono::Utc::now().to_rfc3339(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(&command_payload)
|
let payload_bytes = serde_json::to_vec(&command_payload).map_err(|e| {
|
||||||
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?;
|
crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
state.mqtt_client
|
state
|
||||||
|
.mqtt_client
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
|
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
|
||||||
@@ -120,8 +124,6 @@ pub async fn trigger_ota_update(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::errors::AppError;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct LogQueryParams {
|
pub struct LogQueryParams {
|
||||||
pub level: Option<String>,
|
pub level: Option<String>,
|
||||||
@@ -130,6 +132,8 @@ pub struct LogQueryParams {
|
|||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
/// GET /api/edge/agents/logs
|
/// GET /api/edge/agents/logs
|
||||||
pub async fn list_agent_logs(
|
pub async fn list_agent_logs(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Handlers para activos (Pozos, Tanques, etc.)
|
//! Handlers para activos (Pozos, Tanques, etc.)
|
||||||
|
|
||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::handlers::well_access;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -34,15 +33,29 @@ 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)
|
||||||
.fetch_all(&pool)
|
.bind(project_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.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)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(assets))
|
Ok(Json(assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use chrono;
|
||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono;
|
|
||||||
|
|
||||||
async fn mark_build_dispatch_failed(
|
async fn mark_build_dispatch_failed(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use chrono;
|
||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|||||||
@@ -26,5 +26,4 @@ pub mod telemetry;
|
|||||||
pub mod totp;
|
pub mod totp;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod well_access;
|
|
||||||
pub mod well_tests;
|
pub mod well_tests;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::handlers::{audit_helper, well_access};
|
use crate::handlers::audit_helper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -9,7 +9,7 @@ use axum::{
|
|||||||
use bigdecimal::BigDecimal;
|
use bigdecimal::BigDecimal;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared_lib::models::{MovementType, OperationMovement};
|
use shared_lib::models::{MovementType, OperationMovement};
|
||||||
use sqlx::{PgPool, Postgres};
|
use sqlx::{PgPool, Postgres, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -19,7 +19,6 @@ pub struct CreateMovementRequest {
|
|||||||
pub start_time: chrono::DateTime<chrono::Utc>,
|
pub start_time: chrono::DateTime<chrono::Utc>,
|
||||||
pub end_time: chrono::DateTime<chrono::Utc>,
|
pub end_time: chrono::DateTime<chrono::Utc>,
|
||||||
pub volumen_neto: Option<BigDecimal>,
|
pub volumen_neto: Option<BigDecimal>,
|
||||||
pub client_id: Option<String>,
|
|
||||||
pub observaciones: Option<String>,
|
pub observaciones: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,115 +27,6 @@ pub struct MovementQueryParams {
|
|||||||
pub asset_id: Uuid,
|
pub asset_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn movement_details(
|
|
||||||
observaciones: Option<String>,
|
|
||||||
client_id: Option<String>,
|
|
||||||
created_by: &str,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"observaciones": observaciones,
|
|
||||||
"client_id": client_id,
|
|
||||||
"created_by": created_by
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_client_id(client_id: Option<String>) -> Result<Option<String>, AppError> {
|
|
||||||
let Some(client_id) = client_id else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let trimmed = client_id.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed = Uuid::parse_str(trimmed)
|
|
||||||
.map_err(|_| AppError::BadRequest("client_id debe ser un UUID válido".to_string()))?;
|
|
||||||
|
|
||||||
Ok(Some(parsed.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_existing_movement_by_client_id_sql() -> &'static str {
|
|
||||||
r#"SELECT *
|
|
||||||
FROM operation_movements
|
|
||||||
WHERE details->>'client_id' = $1
|
|
||||||
AND asset_id = $2
|
|
||||||
LIMIT 1"#
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_movement_sql() -> &'static str {
|
|
||||||
r#"INSERT INTO operation_movements (
|
|
||||||
asset_id, movement_type, start_time, end_time, volumen_neto, details
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (asset_id, (details->>'client_id'))
|
|
||||||
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL
|
|
||||||
DO NOTHING
|
|
||||||
RETURNING *"#
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn movement_details_preserve_client_id_for_pwa_idempotency() {
|
|
||||||
let details = movement_details(
|
|
||||||
Some("nota".to_string()),
|
|
||||||
Some("client-1".to_string()),
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(details["observaciones"], "nota");
|
|
||||||
assert_eq!(details["client_id"], "client-1");
|
|
||||||
assert_eq!(details["created_by"], "user-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_client_id_ignores_blank_values() {
|
|
||||||
assert_eq!(normalize_client_id(None).unwrap(), None);
|
|
||||||
assert_eq!(normalize_client_id(Some(" ".to_string())).unwrap(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_client_id_rejects_invalid_non_empty_values() {
|
|
||||||
let result = normalize_client_id(Some("not-a-uuid".to_string()));
|
|
||||||
|
|
||||||
assert!(matches!(result, Err(AppError::BadRequest(message)) if message.contains("UUID")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_client_id_canonicalizes_valid_uuid_values() {
|
|
||||||
let result =
|
|
||||||
normalize_client_id(Some(" 67E55044-10B1-426F-9247-BB680E5FE0C8 ".to_string()))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_existing_movement_by_client_id_filters_inside_details() {
|
|
||||||
let sql = find_existing_movement_by_client_id_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("FROM operation_movements"));
|
|
||||||
assert!(sql.contains("details->>'client_id' = $1"));
|
|
||||||
assert!(sql.contains("asset_id = $2"));
|
|
||||||
assert!(sql.contains("LIMIT 1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn insert_movement_sql_is_conflict_safe_for_client_id() {
|
|
||||||
let sql = insert_movement_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("ON CONFLICT (asset_id, (details->>'client_id'))"));
|
|
||||||
assert!(sql.contains("WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL"));
|
|
||||||
assert!(sql.contains("DO NOTHING"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/movements — Listar movimientos de un activo.
|
/// GET /api/movements — Listar movimientos de un activo.
|
||||||
pub async fn list_movements(
|
pub async fn list_movements(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
@@ -147,14 +37,22 @@ pub async fn list_movements(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
let access = sqlx::query(
|
||||||
&pool,
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
params.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para ver movimientos de este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Sin permiso para ver movimientos de este activo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
|
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
"SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC",
|
"SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC",
|
||||||
)
|
)
|
||||||
@@ -175,58 +73,46 @@ pub async fn create_movement(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
let project_id = well_access::ensure_asset_access(
|
let project_row = sqlx::query(
|
||||||
&pool,
|
r#"SELECT ea.project_id FROM edge_assets ea
|
||||||
user_id,
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
req.asset_id,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
"Sin permiso para registrar movimientos en este activo",
|
|
||||||
)
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let client_id = normalize_client_id(req.client_id)?;
|
let project_id: Uuid = match project_row {
|
||||||
|
Some(row) => row.get("project_id"),
|
||||||
if let Some(client_id) = client_id.as_deref() {
|
None => {
|
||||||
let existing = sqlx::query_as::<Postgres, OperationMovement>(
|
return Err(AppError::Forbidden(
|
||||||
find_existing_movement_by_client_id_sql(),
|
"Sin permiso para registrar movimientos en este activo".to_string(),
|
||||||
)
|
));
|
||||||
.bind(client_id)
|
|
||||||
.bind(req.asset_id)
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(movement) = existing {
|
|
||||||
return Ok((StatusCode::OK, Json(movement)));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let details = movement_details(req.observaciones, client_id.clone(), &claims.sub);
|
|
||||||
|
|
||||||
let inserted = sqlx::query_as::<Postgres, OperationMovement>(insert_movement_sql())
|
|
||||||
.bind(req.asset_id)
|
|
||||||
.bind(req.movement_type)
|
|
||||||
.bind(req.start_time)
|
|
||||||
.bind(req.end_time)
|
|
||||||
.bind(req.volumen_neto)
|
|
||||||
.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)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
return Ok((StatusCode::OK, Json(movement)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"observaciones": req.observaciones,
|
||||||
|
"created_by": claims.sub
|
||||||
|
});
|
||||||
|
|
||||||
|
let movement: OperationMovement = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
|
r#"INSERT INTO operation_movements (
|
||||||
|
asset_id, movement_type, start_time, end_time, volumen_neto, details
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(req.movement_type)
|
||||||
|
.bind(req.start_time)
|
||||||
|
.bind(req.end_time)
|
||||||
|
.bind(req.volumen_neto)
|
||||||
|
.bind(details)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// ─── Auditoría ────────────────────────────────────────────────────────────
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
audit_helper::log_audit(
|
audit_helper::log_audit(
|
||||||
&pool,
|
&pool,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
|
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
|
||||||
|
|
||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::errors::AppError;
|
|
||||||
use crate::handlers::well_access;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -14,79 +12,38 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared_lib::models::{
|
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
|
||||||
PostTelemetryRequest, TelemetryRecord, TelemetryResponse, TelemetrySource,
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn app_error_to_tuple(error: AppError) -> (StatusCode, String) {
|
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
|
||||||
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.
|
|
||||||
///
|
///
|
||||||
/// SCADA/HF agents send telemetry by MQTT (`omnioil/telemetry/{project_id}`), never by HTTP.
|
/// Los agentes de campo envían telemetría exclusivamente por MQTT (`omnioil/telemetry/{id}`),
|
||||||
|
/// nunca por HTTP. Este endpoint es únicamente para el frontend administrativo.
|
||||||
///
|
///
|
||||||
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados.
|
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados.
|
||||||
pub async fn post_telemetry(
|
pub async fn post_telemetry(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
claims: Claims,
|
_claims: Claims,
|
||||||
Json(req): Json<PostTelemetryRequest>,
|
Json(req): Json<PostTelemetryRequest>,
|
||||||
) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> {
|
||||||
let time = req.time.unwrap_or_else(Utc::now);
|
let time = req.time.unwrap_or_else(Utc::now);
|
||||||
let source = resolve_http_telemetry_source(req.source)?;
|
|
||||||
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"User ID inválido en token".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
well_access::ensure_asset_access(
|
sqlx::query(
|
||||||
&pool,
|
r#"INSERT INTO telemetry_raw (time, asset_id, data)
|
||||||
user_id,
|
VALUES ($1, $2, $3)
|
||||||
req.asset_id,
|
ON CONFLICT (time, asset_id) DO NOTHING"#,
|
||||||
"Access denied to this asset's telemetry",
|
|
||||||
)
|
)
|
||||||
|
.bind(time)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(&req.data)
|
||||||
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(app_error_to_tuple)?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let result = sqlx::query(post_telemetry_insert_sql())
|
|
||||||
.bind(time)
|
|
||||||
.bind(req.asset_id)
|
|
||||||
.bind(&req.data)
|
|
||||||
.bind(source)
|
|
||||||
.bind(&req.client_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
tracing::debug!(
|
|
||||||
asset_id = %req.asset_id,
|
|
||||||
time = %time,
|
|
||||||
"Telemetry record already exists; treating POST as idempotent"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
asset_id = %req.asset_id,
|
asset_id = %req.asset_id,
|
||||||
time = %time,
|
time = %time,
|
||||||
source = ?source,
|
|
||||||
"Telemetry record inserted"
|
"Telemetry record inserted"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,8 +51,6 @@ pub async fn post_telemetry(
|
|||||||
time,
|
time,
|
||||||
asset_id: req.asset_id,
|
asset_id: req.asset_id,
|
||||||
data: req.data,
|
data: req.data,
|
||||||
source,
|
|
||||||
client_id: req.client_id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(response)))
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
@@ -153,7 +108,7 @@ pub async fn get_asset_telemetry(
|
|||||||
let since = Utc::now() - Duration::days(31);
|
let since = Utc::now() - Duration::days(31);
|
||||||
|
|
||||||
let records = sqlx::query_as::<_, TelemetryRecord>(
|
let records = sqlx::query_as::<_, TelemetryRecord>(
|
||||||
r#"SELECT time, asset_id, data, source, client_id
|
r#"SELECT time, asset_id, data
|
||||||
FROM telemetry_raw
|
FROM telemetry_raw
|
||||||
WHERE asset_id = $1
|
WHERE asset_id = $1
|
||||||
AND time >= $2
|
AND time >= $2
|
||||||
@@ -170,80 +125,6 @@ pub async fn get_asset_telemetry(
|
|||||||
Ok(Json(records))
|
Ok(Json(records))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn post_telemetry_insert_sql() -> &'static str {
|
|
||||||
r#"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)
|
|
||||||
SELECT $1, ea.project_id, ea.id, $3, $4, $5
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa
|
|
||||||
ON upa.project_id = ea.project_id
|
|
||||||
AND upa.user_id = $6
|
|
||||||
AND upa.is_active = true
|
|
||||||
WHERE ea.id = $2
|
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_http_telemetry_source(
|
|
||||||
source: Option<TelemetrySource>,
|
|
||||||
) -> Result<TelemetrySource, (StatusCode, String)> {
|
|
||||||
match source.unwrap_or(TelemetrySource::Pwa) {
|
|
||||||
TelemetrySource::Scada => Err((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"SCADA telemetry must be ingested via MQTT, not HTTP".to_string(),
|
|
||||||
)),
|
|
||||||
source @ (TelemetrySource::Pwa | TelemetrySource::Manual) => Ok(source),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pwa_insert_derives_project_id_from_asset_and_persists_source_client() {
|
|
||||||
let sql = post_telemetry_insert_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains(
|
|
||||||
"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)"
|
|
||||||
));
|
|
||||||
assert!(sql.contains("SELECT $1, ea.project_id, ea.id, $3, $4, $5"));
|
|
||||||
assert!(sql.contains("FROM edge_assets ea"));
|
|
||||||
assert!(sql.contains("JOIN user_project_access upa"));
|
|
||||||
assert!(sql.contains("upa.project_id = ea.project_id"));
|
|
||||||
assert!(sql.contains("upa.user_id = $6"));
|
|
||||||
assert!(sql.contains("upa.is_active = true"));
|
|
||||||
assert!(sql.contains("WHERE ea.id = $2"));
|
|
||||||
assert!(sql.contains("ON CONFLICT (time, asset_id) DO NOTHING"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn http_source_defaults_to_pwa_and_allows_manual() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_http_telemetry_source(None).unwrap(),
|
|
||||||
TelemetrySource::Pwa
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_http_telemetry_source(Some(TelemetrySource::Manual)).unwrap(),
|
|
||||||
TelemetrySource::Manual
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn http_source_rejects_scada() {
|
|
||||||
let err = resolve_http_telemetry_source(Some(TelemetrySource::Scada)).unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
|
||||||
assert!(err.1.contains("SCADA telemetry must be ingested via MQTT"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_telemetry_uses_well_scoped_asset_access() {
|
|
||||||
let source = include_str!("telemetry.rs");
|
|
||||||
|
|
||||||
assert!(source.contains("well_access::ensure_asset_access"));
|
|
||||||
assert!(source.contains("Access denied to this asset's telemetry"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/telemetry/:asset_id/history — Historial largo (agregado de 5 minutos).
|
/// GET /api/telemetry/:asset_id/history — Historial largo (agregado de 5 minutos).
|
||||||
///
|
///
|
||||||
/// Usa `telemetry_5min` (vista continua de TimescaleDB), que retiene datos por 5 años.
|
/// Usa `telemetry_5min` (vista continua de TimescaleDB), que retiene datos por 5 años.
|
||||||
|
|||||||
@@ -7,14 +7,13 @@
|
|||||||
//!
|
//!
|
||||||
//! Edge Agent ──► GET /api/edge/update-info (solo metadatos)
|
//! Edge Agent ──► GET /api/edge/update-info (solo metadatos)
|
||||||
//! Edge Agent ──► GET /api/edge/download-agent ──► MinIO (sin puerto expuesto)
|
//! Edge Agent ──► GET /api/edge/download-agent ──► MinIO (sin puerto expuesto)
|
||||||
//! │ verifica firma HMAC (ts+sig)
|
//! │ verifica Bearer token
|
||||||
//! │ hace streaming desde MinIO interno
|
//! │ hace streaming desde MinIO interno
|
||||||
//! └──► Edge Agent recibe el binario
|
//! └──► Edge Agent recibe el binario
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! MinIO NUNCA tiene puerto expuesto al exterior (no hay mapeo en docker-compose.yml).
|
//! MinIO NUNCA tiene puerto expuesto al exterior (no hay mapeo en docker-compose.yml).
|
||||||
//! Todo el tráfico pasa por el backend API que valida la firma HMAC de la URL.
|
//! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar.
|
||||||
//! La firma usa AGENT_DOWNLOAD_SECRET y expira a los 5 minutos.
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -23,97 +22,12 @@ use axum::{
|
|||||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
/// Verifica que la URL firmada sea válida y no haya expirado.
|
|
||||||
/// Si AGENT_DOWNLOAD_SECRET no está configurado, opera en modo compatibilidad (sin firma).
|
|
||||||
fn verify_signed_url(message: &str, timestamp: &str, signature: &str, max_age_secs: u64) -> Result<(), Response> {
|
|
||||||
let secret = match std::env::var("AGENT_DOWNLOAD_SECRET") {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
warn!("AGENT_DOWNLOAD_SECRET not set — download authentication disabled");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let ts: u64 = timestamp.parse().map_err(|_| {
|
|
||||||
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid timestamp" }))).into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if now.saturating_sub(ts) > max_age_secs {
|
|
||||||
return Err(
|
|
||||||
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Signature expired" }))).into_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
|
||||||
.map_err(|_| {
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "HMAC error" }))).into_response()
|
|
||||||
})?;
|
|
||||||
mac.update(message.as_bytes());
|
|
||||||
let expected = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
// Comparación en tiempo constante
|
|
||||||
if expected.as_bytes() != signature.as_bytes() {
|
|
||||||
return Err(
|
|
||||||
(StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Invalid signature" }))).into_response()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Genera una URL firmada para descarga del binario.
|
|
||||||
/// Si AGENT_DOWNLOAD_SECRET no está configurado, devuelve URL sin firma (modo compatibilidad).
|
|
||||||
fn generate_signed_url(api_base: &str, version: &str, asset_type: &str) -> String {
|
|
||||||
let secret = match std::env::var("AGENT_DOWNLOAD_SECRET") {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}",
|
|
||||||
api_base.trim_end_matches('/'),
|
|
||||||
version,
|
|
||||||
asset_type,
|
|
||||||
);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let ts = now.to_string();
|
|
||||||
let message = format!("{}:{}", version, ts);
|
|
||||||
|
|
||||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
|
||||||
.expect("HMAC key init");
|
|
||||||
mac.update(message.as_bytes());
|
|
||||||
let sig = hex::encode(mac.finalize().into_bytes());
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"{}/api/edge/download-agent?version={}&asset_type={}&ts={}&sig={}",
|
|
||||||
api_base.trim_end_matches('/'),
|
|
||||||
version,
|
|
||||||
asset_type,
|
|
||||||
ts,
|
|
||||||
sig,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -122,10 +36,6 @@ pub struct UpdateQuery {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
/// Versión específica para rollback/pin (ej. "0.1.9"). Opcional.
|
/// Versión específica para rollback/pin (ej. "0.1.9"). Opcional.
|
||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
/// Timestamp UNIX para firma HMAC.
|
|
||||||
pub ts: Option<String>,
|
|
||||||
/// HMAC-SHA256 signature: hex(version:timestamp).
|
|
||||||
pub sig: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -134,10 +44,6 @@ pub struct DownloadQuery {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
/// Tipo de asset: "msi" | "exe" (default: "msi").
|
/// Tipo de asset: "msi" | "exe" (default: "msi").
|
||||||
pub asset_type: Option<String>,
|
pub asset_type: Option<String>,
|
||||||
/// Timestamp UNIX para firma HMAC.
|
|
||||||
pub ts: Option<String>,
|
|
||||||
/// HMAC-SHA256 signature: hex(version:timestamp).
|
|
||||||
pub sig: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -161,7 +67,7 @@ fn err(status: StatusCode, msg: &str) -> Response {
|
|||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/edge/update-info?version=<current>[&target=<pin>][&ts=<unix>&sig=<hex>]
|
/// GET /api/edge/update-info?version=<current>[&target=<pin>]
|
||||||
///
|
///
|
||||||
/// Devuelve metadatos de la actualización disponible. La `download_url` apunta
|
/// Devuelve metadatos de la actualización disponible. La `download_url` apunta
|
||||||
/// al endpoint `/api/edge/download-agent` del propio backend, no a MinIO.
|
/// al endpoint `/api/edge/download-agent` del propio backend, no a MinIO.
|
||||||
@@ -169,19 +75,6 @@ pub async fn get_update_info(
|
|||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
Query(params): Query<UpdateQuery>,
|
Query(params): Query<UpdateQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 0. Verificar firma HMAC (si AGENT_DOWNLOAD_SECRET está configurado)
|
|
||||||
if let (Some(ts), Some(sig)) = (¶ms.ts, ¶ms.sig) {
|
|
||||||
let msg = format!("{}:{}", params.version, ts);
|
|
||||||
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
|
|
||||||
).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Parsear versión actual
|
// 1. Parsear versión actual
|
||||||
let current = match Version::parse(¶ms.version) {
|
let current = match Version::parse(¶ms.version) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -235,10 +128,14 @@ pub async fn get_update_info(
|
|||||||
// 4. SHA-256: leerlo desde MinIO internamente (el agente no necesita acceder a MinIO)
|
// 4. SHA-256: leerlo desde MinIO internamente (el agente no necesita acceder a MinIO)
|
||||||
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
|
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
|
||||||
|
|
||||||
// 5. download_url apunta al endpoint del BACKEND con firma HMAC, no a MinIO directamente
|
// 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente
|
||||||
let api_base =
|
let api_base =
|
||||||
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
||||||
let download_url = generate_signed_url(&api_base, &latest.to_string(), "msi");
|
let download_url = format!(
|
||||||
|
"{}/api/edge/download-agent?version={}&asset_type=msi",
|
||||||
|
api_base.trim_end_matches('/'),
|
||||||
|
latest
|
||||||
|
);
|
||||||
|
|
||||||
info!("Agent v{} → update available: v{}", current, latest);
|
info!("Agent v{} → update available: v{}", current, latest);
|
||||||
|
|
||||||
@@ -252,7 +149,7 @@ pub async fn get_update_info(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>&ts=<unix>&sig=<hex>
|
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>
|
||||||
///
|
///
|
||||||
/// Proxy autenticado: descarga el binario desde MinIO (red interna) y lo
|
/// Proxy autenticado: descarga el binario desde MinIO (red interna) y lo
|
||||||
/// envía al agente en streaming. El agente nunca ve la URL de MinIO.
|
/// envía al agente en streaming. El agente nunca ve la URL de MinIO.
|
||||||
@@ -260,23 +157,8 @@ pub async fn download_agent(
|
|||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
Query(params): Query<DownloadQuery>,
|
Query(params): Query<DownloadQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 0. Verificar firma HMAC (si AGENT_DOWNLOAD_SECRET está configurado)
|
|
||||||
let asset_type = params.asset_type.as_deref().unwrap_or("msi");
|
let asset_type = params.asset_type.as_deref().unwrap_or("msi");
|
||||||
let version_clean = params.version.trim_start_matches('v');
|
let version = params.version.trim_start_matches('v');
|
||||||
|
|
||||||
if let (Some(ts), Some(sig)) = (¶ms.ts, ¶ms.sig) {
|
|
||||||
let msg = format!("{}:{}", version_clean, ts);
|
|
||||||
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
|
|
||||||
).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = version_clean;
|
|
||||||
|
|
||||||
let minio_internal =
|
let minio_internal =
|
||||||
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
use crate::auth::{AdminClaims, Claims};
|
|
||||||
use crate::errors::AppError;
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use shared_lib::models::EdgeAsset;
|
|
||||||
use sqlx::{PgPool, Row};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SetUserWellsRequest {
|
|
||||||
pub well_ids: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct SetUserWellsResponse {
|
|
||||||
pub message: String,
|
|
||||||
pub count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn project_access_sql() -> &'static str {
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM user_project_access
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND project_id = $2
|
|
||||||
AND is_active = true"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_well_assignments_exist_sql() -> &'static str {
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM user_well_access
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND project_id = $2
|
|
||||||
AND is_active = true
|
|
||||||
LIMIT 1"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asset_access_sql() -> &'static str {
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa
|
|
||||||
ON upa.project_id = ea.project_id
|
|
||||||
AND upa.user_id = $2
|
|
||||||
AND upa.is_active = true
|
|
||||||
WHERE ea.id = $1
|
|
||||||
AND (
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa_any
|
|
||||||
WHERE uwa_any.user_id = $2
|
|
||||||
AND uwa_any.project_id = ea.project_id
|
|
||||||
AND uwa_any.is_active = true
|
|
||||||
)
|
|
||||||
OR ea.asset_type <> 'POZO'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa
|
|
||||||
WHERE uwa.user_id = $2
|
|
||||||
AND uwa.project_id = ea.project_id
|
|
||||||
AND uwa.well_asset_id = ea.id
|
|
||||||
AND uwa.is_active = true
|
|
||||||
)
|
|
||||||
)"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn project_assets_sql() -> &'static str {
|
|
||||||
r#"SELECT ea.*
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_project_access upa
|
|
||||||
ON upa.project_id = ea.project_id
|
|
||||||
AND upa.user_id = $2
|
|
||||||
AND upa.is_active = true
|
|
||||||
WHERE ea.project_id = $1
|
|
||||||
AND (
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa_any
|
|
||||||
WHERE uwa_any.user_id = $2
|
|
||||||
AND uwa_any.project_id = ea.project_id
|
|
||||||
AND uwa_any.is_active = true
|
|
||||||
)
|
|
||||||
OR ea.asset_type <> 'POZO'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_well_access uwa
|
|
||||||
WHERE uwa.user_id = $2
|
|
||||||
AND uwa.project_id = ea.project_id
|
|
||||||
AND uwa.well_asset_id = ea.id
|
|
||||||
AND uwa.is_active = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY ea.created_at DESC"#
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_project_access(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let access = sqlx::query(project_access_sql())
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if access.is_none() {
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Sin acceso a este proyecto".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_asset_access(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
asset_id: Uuid,
|
|
||||||
forbidden_message: &str,
|
|
||||||
) -> Result<Uuid, AppError> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"SELECT ea.project_id
|
|
||||||
FROM edge_assets ea
|
|
||||||
WHERE ea.id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(asset_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or(AppError::NotFound("Activo".to_string()))?;
|
|
||||||
|
|
||||||
let project_id: Uuid = row.get("project_id");
|
|
||||||
let access = sqlx::query(asset_access_sql())
|
|
||||||
.bind(asset_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if access.is_none() {
|
|
||||||
return Err(AppError::Forbidden(forbidden_message.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(project_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_claim_user_id(claims: &Claims) -> Result<Uuid, AppError> {
|
|
||||||
Uuid::parse_str(&claims.sub).map_err(|_| AppError::BadRequest("User ID inválido".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_superadmin(claims: &Claims) -> bool {
|
|
||||||
claims.role == "superadmin"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_admin_project_access(
|
|
||||||
pool: &PgPool,
|
|
||||||
admin: &AdminClaims,
|
|
||||||
project_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if is_superadmin(&admin.0) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_project_access(pool, parse_claim_user_id(&admin.0)?, project_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_project_wells(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
admin: AdminClaims,
|
|
||||||
Path(project_id): Path<Uuid>,
|
|
||||||
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
|
||||||
ensure_admin_project_access(&pool, &admin, project_id).await?;
|
|
||||||
|
|
||||||
let wells = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
r#"SELECT *
|
|
||||||
FROM edge_assets
|
|
||||||
WHERE project_id = $1
|
|
||||||
AND asset_type = 'POZO'
|
|
||||||
AND is_active = true
|
|
||||||
ORDER BY name ASC"#,
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(wells))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_wells(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
admin: AdminClaims,
|
|
||||||
Path(user_id): Path<Uuid>,
|
|
||||||
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
|
|
||||||
if is_superadmin(&admin.0) {
|
|
||||||
let wells = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
r#"SELECT ea.*
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_well_access uwa ON uwa.well_asset_id = ea.id
|
|
||||||
WHERE uwa.user_id = $1
|
|
||||||
AND uwa.is_active = true
|
|
||||||
ORDER BY ea.name ASC"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
return Ok(Json(wells));
|
|
||||||
}
|
|
||||||
|
|
||||||
let admin_id = parse_claim_user_id(&admin.0)?;
|
|
||||||
|
|
||||||
let wells = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
r#"SELECT ea.*
|
|
||||||
FROM edge_assets ea
|
|
||||||
JOIN user_well_access uwa ON uwa.well_asset_id = ea.id
|
|
||||||
JOIN user_project_access admin_upa ON admin_upa.project_id = ea.project_id
|
|
||||||
WHERE uwa.user_id = $1
|
|
||||||
AND uwa.is_active = true
|
|
||||||
AND admin_upa.user_id = $2
|
|
||||||
AND admin_upa.is_active = true
|
|
||||||
ORDER BY ea.name ASC"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(admin_id)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(wells))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_user_wells(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
admin: AdminClaims,
|
|
||||||
Path(user_id): Path<Uuid>,
|
|
||||||
Json(req): Json<SetUserWellsRequest>,
|
|
||||||
) -> Result<Json<SetUserWellsResponse>, AppError> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
if is_superadmin(&admin.0) {
|
|
||||||
sqlx::query("DELETE FROM user_well_access WHERE user_id = $1")
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
let admin_id = parse_claim_user_id(&admin.0)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"DELETE FROM user_well_access uwa
|
|
||||||
USING user_project_access admin_upa
|
|
||||||
WHERE uwa.user_id = $1
|
|
||||||
AND admin_upa.user_id = $2
|
|
||||||
AND admin_upa.project_id = uwa.project_id
|
|
||||||
AND admin_upa.is_active = true"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(admin_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for well_id in &req.well_ids {
|
|
||||||
let well = sqlx::query(
|
|
||||||
r#"SELECT id, project_id
|
|
||||||
FROM edge_assets
|
|
||||||
WHERE id = $1
|
|
||||||
AND asset_type = 'POZO'
|
|
||||||
AND is_active = true"#,
|
|
||||||
)
|
|
||||||
.bind(well_id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.ok_or(AppError::BadRequest(
|
|
||||||
"Solo se pueden asignar pozos activos".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let project_id: Uuid = well.get("project_id");
|
|
||||||
ensure_admin_project_access(&pool, &admin, project_id).await?;
|
|
||||||
ensure_project_access(&pool, user_id, project_id).await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"INSERT INTO user_well_access (user_id, project_id, well_asset_id, is_active)
|
|
||||||
VALUES ($1, $2, $3, true)
|
|
||||||
ON CONFLICT (user_id, well_asset_id)
|
|
||||||
DO UPDATE SET project_id = EXCLUDED.project_id,
|
|
||||||
is_active = true,
|
|
||||||
updated_at = NOW()"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.bind(well_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(Json(SetUserWellsResponse {
|
|
||||||
message: "Pozos asignados exitosamente".to_string(),
|
|
||||||
count: req.well_ids.len(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn asset_access_is_project_access_with_optional_well_scope() {
|
|
||||||
let sql = asset_access_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("JOIN user_project_access upa"));
|
|
||||||
assert!(sql.contains("NOT EXISTS"));
|
|
||||||
assert!(sql.contains("FROM user_well_access uwa_any"));
|
|
||||||
assert!(sql.contains("ea.asset_type <> 'POZO'"));
|
|
||||||
assert!(sql.contains("uwa.well_asset_id = ea.id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_assets_are_filtered_by_well_assignments_when_present() {
|
|
||||||
let sql = project_assets_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("SELECT ea.*"));
|
|
||||||
assert!(sql.contains("ea.project_id = $1"));
|
|
||||||
assert!(sql.contains("upa.user_id = $2"));
|
|
||||||
assert!(sql.contains("ea.asset_type <> 'POZO'"));
|
|
||||||
assert!(sql.contains("uwa.well_asset_id = ea.id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_superadmin_replacement_deletes_only_visible_scope() {
|
|
||||||
let source = include_str!("well_access.rs");
|
|
||||||
|
|
||||||
assert!(source.contains("DELETE FROM user_well_access uwa"));
|
|
||||||
assert!(source.contains("USING user_project_access admin_upa"));
|
|
||||||
assert!(source.contains("admin_upa.project_id = uwa.project_id"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,13 @@ 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 notifier;
|
pub mod notifier;
|
||||||
pub mod ws;
|
|
||||||
pub mod passwords;
|
pub mod passwords;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod smtp_mailer;
|
pub mod smtp_mailer;
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
|
extract::{
|
||||||
|
Query, State,
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
routing::{get, patch, post, put},
|
routing::{get, patch, post, put},
|
||||||
};
|
};
|
||||||
use backend_api::{
|
use backend_api::{
|
||||||
AppState, TelemetryEvent, api_version, crypto, db, handlers, metrics, mqtt_loop,
|
AppState, TelemetryEvent, api_version, auth, crypto, db, handlers, metrics, notifier,
|
||||||
rate_limiter, watchdog, ws,
|
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,7 +126,7 @@ 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 state = AppState {
|
let state = AppState {
|
||||||
@@ -125,18 +135,256 @@ async fn main() {
|
|||||||
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
|
||||||
)
|
{
|
||||||
.await;
|
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;
|
||||||
|
}
|
||||||
|
// 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/") {
|
||||||
|
// ── 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 +585,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 +634,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 +668,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 +682,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 +709,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 +914,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 +939,298 @@ async fn main() {
|
|||||||
async fn root() -> axum::Json<serde_json::Value> {
|
async fn root() -> axum::Json<serde_json::Value> {
|
||||||
axum::Json(serde_json::json!({"status": "ok"}))
|
axum::Json(serde_json::json!({"status": "ok"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WsParams {
|
||||||
|
project_id: Option<String>,
|
||||||
|
/// Short-lived opaque one-time ticket. Never pass the primary JWT in the URL.
|
||||||
|
ticket: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateWsTicketRequest {
|
||||||
|
project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateWsTicketResponse {
|
||||||
|
ticket: String,
|
||||||
|
expires_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_TICKET_TTL_SECONDS: u64 = 60;
|
||||||
|
|
||||||
|
async fn create_ws_telemetry_ticket(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: auth::Claims,
|
||||||
|
axum::Json(req): axum::Json<CreateWsTicketRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
|
||||||
|
Ok(user_id) => user_id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(serde_json::json!({"error": "Usuario inválido"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
|
||||||
|
Ok(project_id) => project_id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
axum::Json(serde_json::json!({"error": "project_id inválido"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_can_access_project(&state.pool, user_id, project_id).await {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
axum::Json(serde_json::json!({"error": "Access denied to this project"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(error = %error, "Failed to check WebSocket project access");
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let token_hash = hash_ws_ticket(&ticket);
|
||||||
|
let expires_in = WS_TICKET_TTL_SECONDS as i64;
|
||||||
|
|
||||||
|
if let Err(error) = sqlx::query(
|
||||||
|
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(format!("{} seconds", expires_in))
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({"error": "Ticket creation failed"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
axum::Json(CreateWsTicketResponse {
|
||||||
|
ticket,
|
||||||
|
expires_in: WS_TICKET_TTL_SECONDS,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_telemetry_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
query: Query<WsParams>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let ticket = match &query.ticket {
|
||||||
|
Some(ticket) => ticket.as_str(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
axum::Json(
|
||||||
|
serde_json::json!({"error": "Ticket requerido para conexión WebSocket"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
|
||||||
|
{
|
||||||
|
Ok(project_id) => project_id,
|
||||||
|
Err(status) => {
|
||||||
|
let message = if status == axum::http::StatusCode::FORBIDDEN {
|
||||||
|
"Ticket no corresponde al proyecto solicitado"
|
||||||
|
} else {
|
||||||
|
"Ticket inválido o expirado"
|
||||||
|
};
|
||||||
|
return (status, axum::Json(serde_json::json!({"error": message}))).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_id = Some(project_id.to_string());
|
||||||
|
tracing::debug!(
|
||||||
|
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ws_ticket(ticket: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(ticket.as_bytes());
|
||||||
|
hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_can_access_project(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
project_id: uuid::Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1
|
||||||
|
FROM user_project_access
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
AND is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(access.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume_ws_ticket(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
ticket: &str,
|
||||||
|
requested_project_id: Option<&str>,
|
||||||
|
) -> Result<uuid::Uuid, axum::http::StatusCode> {
|
||||||
|
let token_hash = hash_ws_ticket(ticket);
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"UPDATE websocket_tickets
|
||||||
|
SET used_at = NOW()
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > NOW()
|
||||||
|
RETURNING project_id"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?
|
||||||
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let project_id: uuid::Uuid = row.get("project_id");
|
||||||
|
|
||||||
|
if let Some(requested_project_id) = requested_project_id {
|
||||||
|
let requested = uuid::Uuid::parse_str(requested_project_id)
|
||||||
|
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
if requested != project_id {
|
||||||
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
||||||
|
filter_project_id
|
||||||
|
);
|
||||||
|
let mut rx = state.tx.subscribe();
|
||||||
|
|
||||||
|
// Ping interval para detectar conexiones zombi
|
||||||
|
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||||
|
ping_interval.tick().await; // consume el tick inmediato
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Branch 1: Recibir evento del broadcast y enviarlo al cliente
|
||||||
|
result = rx.recv() => {
|
||||||
|
match result {
|
||||||
|
Ok(event) => {
|
||||||
|
if let Some(ref target_id) = filter_project_id {
|
||||||
|
// target_id debe normalizarse una vez fuera del loop para máxima velocidad
|
||||||
|
if &*event.project_id != target_id.to_lowercase() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
|
||||||
|
tracing::debug!("WebSocket client disconnected (send failed)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
tracing::warn!("WebSocket receiver lagged by {} messages", count);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
tracing::error!("Broadcast channel closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Branch 2: Leer mensajes del cliente (detectar Close/Pong/desconexión)
|
||||||
|
msg = socket.recv() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
|
tracing::debug!("WebSocket client closed connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Pong(_))) => {
|
||||||
|
// Cliente respondió al ping, la conexión está viva
|
||||||
|
}
|
||||||
|
Some(Err(_)) => {
|
||||||
|
tracing::debug!("WebSocket read error, dropping connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {} // Ignorar otros mensajes (Text, Binary del cliente)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Branch 3: Enviar ping periódico para detectar zombis
|
||||||
|
_ = ping_interval.tick() => {
|
||||||
|
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
|
||||||
|
tracing::debug!("WebSocket ping failed, client is dead");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 WebSocket connection closed for project: {:?}",
|
||||||
|
filter_project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_ticket_hash_never_stores_raw_ticket() {
|
||||||
|
let ticket = "wst_example-ticket";
|
||||||
|
let hash = hash_ws_ticket(ticket);
|
||||||
|
|
||||||
|
assert_ne!(hash, ticket);
|
||||||
|
assert_eq!(hash.len(), 64);
|
||||||
|
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
|
||||||
|
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
|
||||||
|
|
||||||
|
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
|
||||||
|
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
|
||||||
|
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
|
||||||
|
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,30 +7,10 @@ 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"
|
||||||
);
|
);
|
||||||
describe_counter!(
|
|
||||||
"omnioil_telemetry_unresolved_device_total",
|
|
||||||
"MQTT telemetry messages dropped because the device could not be resolved"
|
|
||||||
);
|
|
||||||
describe_counter!(
|
|
||||||
"omnioil_telemetry_ambiguous_device_total",
|
|
||||||
"MQTT telemetry messages dropped because device resolution matched multiple assets"
|
|
||||||
);
|
|
||||||
describe_counter!(
|
|
||||||
"omnioil_telemetry_project_mismatch_total",
|
|
||||||
"MQTT telemetry messages dropped because payload project_id mismatched the topic project_id"
|
|
||||||
);
|
|
||||||
describe_counter!(
|
describe_counter!(
|
||||||
"omnioil_alarms_triggered_total",
|
"omnioil_alarms_triggered_total",
|
||||||
"Total threshold alarms triggered"
|
"Total threshold alarms triggered"
|
||||||
@@ -45,30 +25,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_telemetry_unresolved_device() {
|
|
||||||
counter!("omnioil_telemetry_unresolved_device_total").increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_telemetry_ambiguous_device() {
|
|
||||||
counter!("omnioil_telemetry_ambiguous_device_total").increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_telemetry_project_mismatch() {
|
|
||||||
counter!("omnioil_telemetry_project_mismatch_total").increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
||||||
if let Ok(count) = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_projects")
|
if let Ok(count) = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_projects")
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
@@ -83,20 +43,4 @@ pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
|||||||
{
|
{
|
||||||
gauge!("omnioil_active_tags").set(count as f64);
|
gauge!("omnioil_active_tags").set(count as f64);
|
||||||
}
|
}
|
||||||
if let Ok(count) = sqlx::query_scalar::<_, i64>(
|
|
||||||
r#"SELECT COUNT(*) FROM edge_projects p
|
|
||||||
WHERE p.is_active = true
|
|
||||||
AND p.installer_status = 'COMPLETED'
|
|
||||||
AND p.last_health_report ? 'timestamp'
|
|
||||||
AND (p.last_health_report->>'timestamp')::TIMESTAMPTZ > NOW() - INTERVAL '5 minutes'"#,
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
gauge!("omnioil_active_agents").set(count as f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pool metrics
|
|
||||||
gauge!("omnioil_db_pool_size").set(pool.size() as f64);
|
|
||||||
gauge!("omnioil_db_pool_idle").set(pool.num_idle() as f64);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
TelemetryEvent, metrics, notifier,
|
|
||||||
};
|
|
||||||
use rumqttc::{AsyncClient, Event, Packet};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
/// Procesa mensajes MQTT de `omnioil/#` y `provision/request/+`.
|
|
||||||
///
|
|
||||||
/// Responsabilidades únicas de backend-api (no delegadas a telemetry-ingestor):
|
|
||||||
/// - `omnioil/status/`: estado LWT/reconexión → DB + WS
|
|
||||||
/// - `omnioil/alarms/`: dispatch de notificaciones (email/SMS) + WS relay
|
|
||||||
/// - `omnioil/opcua-scan/`: resultados de scan OPC UA → DB
|
|
||||||
/// - `omnioil/*` catch-all: relay genérico a WebSocket (real-time UI)
|
|
||||||
/// - `provision/request/+`: provisioning de agentes
|
|
||||||
///
|
|
||||||
/// Topics duplicados con telemetry-ingestor (solo WS relay, sin DB):
|
|
||||||
/// - `omnioil/health/`, `omnioil/logs/`, `omnioil/telemetry/`
|
|
||||||
pub async fn run_event_loop(
|
|
||||||
mut eventloop: rumqttc::EventLoop,
|
|
||||||
mqtt_client: AsyncClient,
|
|
||||||
pool: PgPool,
|
|
||||||
tx: broadcast::Sender<TelemetryEvent>,
|
|
||||||
) {
|
|
||||||
let tx_relay = tx.clone();
|
|
||||||
let prov_pool = pool.clone();
|
|
||||||
let prov_mqtt_client = mqtt_client.clone();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match eventloop.poll().await {
|
|
||||||
Ok(notification) => {
|
|
||||||
match notification {
|
|
||||||
Event::Incoming(Packet::Publish(publish)) => {
|
|
||||||
let topic = publish.topic.as_str();
|
|
||||||
|
|
||||||
if topic.starts_with("omnioil/status/") {
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/status/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(msg) =
|
|
||||||
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let status =
|
|
||||||
msg["status"].as_str().unwrap_or("offline").to_string();
|
|
||||||
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
|
|
||||||
{
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE edge_projects
|
|
||||||
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|
|
||||||
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
|
|
||||||
WHERE id = $2"#,
|
|
||||||
)
|
|
||||||
.bind(&status)
|
|
||||||
.bind(project_uuid)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let event = TelemetryEvent {
|
|
||||||
project_id: Arc::from(project_id_str.to_lowercase()),
|
|
||||||
payload: Arc::from(
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "agent_status",
|
|
||||||
"status": status,
|
|
||||||
"timestamp": msg["timestamp"]
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let _ = tx_ref.send(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/alarms/") {
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/alarms/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(event) = serde_json::from_slice::<
|
|
||||||
shared_lib::mqtt_messages::AlarmEvent,
|
|
||||||
>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let alarm = notifier::AlarmNotification {
|
|
||||||
project_id: event.project_id,
|
|
||||||
asset_id: None,
|
|
||||||
variable: event
|
|
||||||
.variable_alias
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
value: event.current_value.unwrap_or(0.0),
|
|
||||||
alarm_type: format!("{:?}", event.alarm_type),
|
|
||||||
message: event.message.unwrap_or_default(),
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
};
|
|
||||||
// Dispatch notificaciones (email/SMS) — única responsabilidad de backend-api
|
|
||||||
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
|
|
||||||
project_id_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/opcua-scan/") {
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(result) =
|
|
||||||
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let scan_id_str = result
|
|
||||||
.get("scan_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let status = result
|
|
||||||
.get("status")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("failed");
|
|
||||||
let error_msg = result.get("error").and_then(|v| v.as_str());
|
|
||||||
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE opcua_scan_results
|
|
||||||
SET status = $1, result = $2, error_message = $3
|
|
||||||
WHERE id = $4"#,
|
|
||||||
)
|
|
||||||
.bind(status)
|
|
||||||
.bind(&result)
|
|
||||||
.bind(error_msg)
|
|
||||||
.bind(scan_id)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
tracing::info!(
|
|
||||||
"✅ OPC UA scan result stored: {} ({})",
|
|
||||||
scan_id,
|
|
||||||
status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/") {
|
|
||||||
// Relay genérico: telemetría, alarmas, deploys, health, logs → WebSocket
|
|
||||||
let topic_parts: Vec<&str> = topic.split('/').collect();
|
|
||||||
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
|
|
||||||
|
|
||||||
metrics::record_telemetry_received();
|
|
||||||
let decompressed =
|
|
||||||
shared_lib::compression::decompress_if_needed(&publish.payload);
|
|
||||||
|
|
||||||
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
|
|
||||||
let event = TelemetryEvent {
|
|
||||||
project_id: Arc::from(project_id.to_lowercase()),
|
|
||||||
payload: Arc::from(payload_str),
|
|
||||||
};
|
|
||||||
match tx_relay.send(event) {
|
|
||||||
Ok(receivers) => {
|
|
||||||
tracing::debug!(
|
|
||||||
"📥 MQTT relayed to {} WS listeners",
|
|
||||||
receivers
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("⚠️ No WS listeners: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if topic.starts_with("provision/request/") {
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("provision/request/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let client_ref = prov_mqtt_client.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = crate::handlers::provisioning::handle_mqtt_provisioning(
|
|
||||||
&pool_ref,
|
|
||||||
&client_ref,
|
|
||||||
&project_id_str,
|
|
||||||
&payload_bytes,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"❌ Error en provisioning MQTT para proyecto {}: {}",
|
|
||||||
project_id_str,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Incoming(Packet::ConnAck(_)) => {
|
|
||||||
metrics::record_mqtt_connection();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("MQTT connection error: {:?}", e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ use std::time::Duration;
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const TELEMETRY_SILENCE_HF: &str = "TELEMETRY_SILENCE_HF";
|
|
||||||
const TELEMETRY_SILENCE_LF: &str = "TELEMETRY_SILENCE_LF";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct SilenceSpec {
|
|
||||||
source: &'static str,
|
|
||||||
frequency_class: &'static str,
|
|
||||||
alarm_type: &'static str,
|
|
||||||
window: chrono::Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) {
|
pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) {
|
||||||
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
|
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
|
||||||
|
|
||||||
@@ -66,133 +55,73 @@ async fn check_telemetry_silence(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
for spec in telemetry_silence_specs() {
|
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
|
||||||
let window = format_window_interval(spec.window);
|
let rows = sqlx::query(
|
||||||
let rows = sqlx::query(telemetry_silence_select_sql())
|
r#"
|
||||||
.bind(spec.frequency_class)
|
|
||||||
.bind(&window)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
let proj_id: Uuid = row.get("id");
|
|
||||||
let proj_name: String = row.get("name");
|
|
||||||
let msg = format!(
|
|
||||||
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
|
|
||||||
source = spec.source,
|
|
||||||
frequency_class = spec.frequency_class,
|
|
||||||
);
|
|
||||||
|
|
||||||
let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
|
|
||||||
.bind(proj_id)
|
|
||||||
.bind(spec.alarm_type)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if already_alerted.is_none() {
|
|
||||||
tracing::warn!(
|
|
||||||
source = spec.source,
|
|
||||||
frequency_class = spec.frequency_class,
|
|
||||||
"⚠️ Detectado silencio prolongado en project: {}",
|
|
||||||
proj_name
|
|
||||||
);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
|
||||||
VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
|
|
||||||
)
|
|
||||||
.bind(proj_id)
|
|
||||||
.bind(spec.alarm_type)
|
|
||||||
.bind(&msg)
|
|
||||||
.bind(Utc::now())
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let alarm = AlarmNotification {
|
|
||||||
project_id: proj_id,
|
|
||||||
asset_id: None,
|
|
||||||
variable: spec.alarm_type.to_string(),
|
|
||||||
value: 0.0,
|
|
||||||
alarm_type: spec.alarm_type.to_string(),
|
|
||||||
message: msg,
|
|
||||||
timestamp: Utc::now(),
|
|
||||||
};
|
|
||||||
notifier::dispatch(pool, &alarm, tx).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn telemetry_silence_specs() -> [SilenceSpec; 2] {
|
|
||||||
let hf_minutes = std::env::var("OMNIOIL_TELEMETRY_SILENCE_HF_MINUTES")
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.parse::<i64>().ok())
|
|
||||||
.filter(|value| *value > 0);
|
|
||||||
let lf_hours = std::env::var("OMNIOIL_TELEMETRY_SILENCE_LF_HOURS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.parse::<i64>().ok())
|
|
||||||
.filter(|value| *value > 0);
|
|
||||||
|
|
||||||
telemetry_silence_specs_from_values(hf_minutes, lf_hours)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn telemetry_silence_specs_from_values(
|
|
||||||
hf_minutes: Option<i64>,
|
|
||||||
lf_hours: Option<i64>,
|
|
||||||
) -> [SilenceSpec; 2] {
|
|
||||||
let hf_minutes = hf_minutes.unwrap_or(10);
|
|
||||||
let lf_hours = lf_hours.unwrap_or(24);
|
|
||||||
|
|
||||||
[
|
|
||||||
SilenceSpec {
|
|
||||||
source: "SCADA",
|
|
||||||
frequency_class: "HF",
|
|
||||||
alarm_type: TELEMETRY_SILENCE_HF,
|
|
||||||
window: chrono::Duration::minutes(hf_minutes),
|
|
||||||
},
|
|
||||||
SilenceSpec {
|
|
||||||
source: "PWA|MANUAL",
|
|
||||||
frequency_class: "LF",
|
|
||||||
alarm_type: TELEMETRY_SILENCE_LF,
|
|
||||||
window: chrono::Duration::hours(lf_hours),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_window_interval(window: chrono::Duration) -> String {
|
|
||||||
format!("{} seconds", window.num_seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn telemetry_silence_select_sql() -> &'static str {
|
|
||||||
r#"
|
|
||||||
SELECT p.id, p.name
|
SELECT p.id, p.name
|
||||||
FROM edge_projects p
|
FROM edge_projects p
|
||||||
WHERE p.is_active = true
|
WHERE p.is_active = true
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM telemetry_raw t
|
SELECT 1 FROM telemetry_raw t
|
||||||
WHERE t.project_id = p.id
|
WHERE t.project_id = p.id
|
||||||
AND CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1
|
AND t.time > NOW() - INTERVAL '10 minutes'
|
||||||
AND t.time > NOW() - ($2::text)::interval
|
|
||||||
)
|
)
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
||||||
)
|
)
|
||||||
"#
|
"#,
|
||||||
}
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
pub(crate) fn telemetry_silence_recent_alarm_sql() -> &'static str {
|
for row in rows {
|
||||||
r#"
|
let proj_id: Uuid = row.get("id");
|
||||||
|
let proj_name: String = row.get("name");
|
||||||
|
let msg = format!(
|
||||||
|
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
|
||||||
|
proj_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let already_alerted = sqlx::query(
|
||||||
|
r#"
|
||||||
SELECT 1 FROM telemetry_alarms
|
SELECT 1 FROM telemetry_alarms
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND (
|
AND alarm_type = 'TELEMETRY_SILENCE'
|
||||||
alarm_type = $2
|
|
||||||
OR ($2 = 'TELEMETRY_SILENCE_HF' AND alarm_type = 'TELEMETRY_SILENCE')
|
|
||||||
)
|
|
||||||
AND timestamp > NOW() - INTERVAL '1 hour'
|
AND timestamp > NOW() - INTERVAL '1 hour'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#
|
"#,
|
||||||
|
)
|
||||||
|
.bind(proj_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if already_alerted.is_none() {
|
||||||
|
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
|
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
|
||||||
|
)
|
||||||
|
.bind(proj_id)
|
||||||
|
.bind(&msg)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let alarm = AlarmNotification {
|
||||||
|
project_id: proj_id,
|
||||||
|
asset_id: None,
|
||||||
|
variable: "TELEMETRY_SILENCE".to_string(),
|
||||||
|
value: 0.0,
|
||||||
|
alarm_type: "TELEMETRY_SILENCE".to_string(),
|
||||||
|
message: msg,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
notifier::dispatch(pool, &alarm, tx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_unhealthy_agents(
|
async fn check_unhealthy_agents(
|
||||||
@@ -501,36 +430,6 @@ mod tests {
|
|||||||
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
|
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
|
||||||
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
|
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn telemetry_silence_select_uses_source_derived_frequency_classes() {
|
|
||||||
let sql = telemetry_silence_select_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("FROM telemetry_raw t"));
|
|
||||||
assert!(sql.contains("CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1"));
|
|
||||||
assert!(sql.contains("t.time > NOW() - ($2::text)::interval"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn telemetry_silence_recent_alarm_maps_legacy_silence_to_hf() {
|
|
||||||
let sql = telemetry_silence_recent_alarm_sql();
|
|
||||||
|
|
||||||
assert!(sql.contains("alarm_type = $2"));
|
|
||||||
assert!(sql.contains("$2 = 'TELEMETRY_SILENCE_HF'"));
|
|
||||||
assert!(sql.contains("alarm_type = 'TELEMETRY_SILENCE'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn telemetry_silence_defaults_are_hf_ten_minutes_and_lf_twenty_four_hours() {
|
|
||||||
let specs = telemetry_silence_specs_from_values(None, None);
|
|
||||||
|
|
||||||
assert_eq!(specs[0].frequency_class, "HF");
|
|
||||||
assert_eq!(specs[0].alarm_type, TELEMETRY_SILENCE_HF);
|
|
||||||
assert_eq!(format_window_interval(specs[0].window), "600 seconds");
|
|
||||||
assert_eq!(specs[1].frequency_class, "LF");
|
|
||||||
assert_eq!(specs[1].alarm_type, TELEMETRY_SILENCE_LF);
|
|
||||||
assert_eq!(format_window_interval(specs[1].window), "86400 seconds");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_calibration_expirations(
|
async fn check_calibration_expirations(
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Query, State, ws::{Message, WebSocket, WebSocketUpgrade}},
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::{get, post},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use crate::{
|
|
||||||
auth, AppState,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use sqlx::Row;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(crate) struct WsParams {
|
|
||||||
project_id: Option<String>,
|
|
||||||
ticket: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub(crate) struct CreateWsTicketRequest {
|
|
||||||
project_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub(crate) struct CreateWsTicketResponse {
|
|
||||||
ticket: String,
|
|
||||||
expires_in: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
const WS_TICKET_TTL_SECONDS: u64 = 60;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
|
|
||||||
.route("/api/ws/telemetry", get(ws_telemetry_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_ws_telemetry_ticket(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
claims: auth::Claims,
|
|
||||||
Json(req): Json<CreateWsTicketRequest>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
|
|
||||||
Ok(user_id) => user_id,
|
|
||||||
Err(_) => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
|
||||||
Json(serde_json::json!({"error": "Usuario inválido"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
|
|
||||||
Ok(project_id) => project_id,
|
|
||||||
Err(_) => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({"error": "project_id inválido"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match user_can_access_project(&state.pool, user_id, project_id).await {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::FORBIDDEN,
|
|
||||||
Json(serde_json::json!({"error": "Access denied to this project"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(error = %error, "Failed to check WebSocket project access");
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(serde_json::json!({"error": "Ticket creation failed"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
|
|
||||||
let token_hash = hash_ws_ticket(&ticket);
|
|
||||||
let expires_in = WS_TICKET_TTL_SECONDS as i64;
|
|
||||||
|
|
||||||
if let Err(error) = sqlx::query(
|
|
||||||
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
|
|
||||||
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
|
|
||||||
)
|
|
||||||
.bind(token_hash)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.bind(format!("{} seconds", expires_in))
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(serde_json::json!({"error": "Ticket creation failed"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
Json(CreateWsTicketResponse {
|
|
||||||
ticket,
|
|
||||||
expires_in: WS_TICKET_TTL_SECONDS,
|
|
||||||
})
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ws_telemetry_handler(
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
query: Query<WsParams>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let ticket = match &query.ticket {
|
|
||||||
Some(ticket) => ticket.as_str(),
|
|
||||||
None => {
|
|
||||||
return (
|
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
|
||||||
Json(serde_json::json!({"error": "Ticket requerido para conexión WebSocket"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let project_id = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
|
|
||||||
{
|
|
||||||
Ok(project_id) => project_id,
|
|
||||||
Err(status) => {
|
|
||||||
let message = if status == axum::http::StatusCode::FORBIDDEN {
|
|
||||||
"Ticket no corresponde al proyecto solicitado"
|
|
||||||
} else {
|
|
||||||
"Ticket inválido o expirado"
|
|
||||||
};
|
|
||||||
return (status, Json(serde_json::json!({"error": message}))).into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let project_id = Some(project_id.to_string());
|
|
||||||
tracing::debug!(
|
|
||||||
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
|
||||||
project_id
|
|
||||||
);
|
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_ws_ticket(ticket: &str) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(ticket.as_bytes());
|
|
||||||
hasher
|
|
||||||
.finalize()
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn user_can_access_project(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
project_id: uuid::Uuid,
|
|
||||||
) -> Result<bool, sqlx::Error> {
|
|
||||||
let access = sqlx::query(
|
|
||||||
r#"SELECT 1
|
|
||||||
FROM user_project_access
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND project_id = $2
|
|
||||||
AND is_active = true"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(access.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn consume_ws_ticket(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
ticket: &str,
|
|
||||||
requested_project_id: Option<&str>,
|
|
||||||
) -> Result<uuid::Uuid, axum::http::StatusCode> {
|
|
||||||
let token_hash = hash_ws_ticket(ticket);
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"UPDATE websocket_tickets
|
|
||||||
SET used_at = NOW()
|
|
||||||
WHERE token_hash = $1
|
|
||||||
AND used_at IS NULL
|
|
||||||
AND expires_at > NOW()
|
|
||||||
RETURNING project_id"#,
|
|
||||||
)
|
|
||||||
.bind(token_hash)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?
|
|
||||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
let project_id: uuid::Uuid = row.get("project_id");
|
|
||||||
|
|
||||||
if let Some(requested_project_id) = requested_project_id {
|
|
||||||
let requested = uuid::Uuid::parse_str(requested_project_id)
|
|
||||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
|
||||||
if requested != project_id {
|
|
||||||
return Err(axum::http::StatusCode::FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(project_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
|
||||||
tracing::info!(
|
|
||||||
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
|
||||||
filter_project_id
|
|
||||||
);
|
|
||||||
let mut rx = state.tx.subscribe();
|
|
||||||
|
|
||||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
|
||||||
ping_interval.tick().await;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
result = rx.recv() => {
|
|
||||||
match result {
|
|
||||||
Ok(event) => {
|
|
||||||
if let Some(ref target_id) = filter_project_id {
|
|
||||||
if &*event.project_id != target_id.to_lowercase() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
|
|
||||||
tracing::debug!("WebSocket client disconnected (send failed)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
|
||||||
tracing::warn!("WebSocket receiver lagged by {} messages", count);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
||||||
tracing::error!("Broadcast channel closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg = socket.recv() => {
|
|
||||||
match msg {
|
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
|
||||||
tracing::debug!("WebSocket client closed connection");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Pong(_))) => {}
|
|
||||||
Some(Err(_)) => {
|
|
||||||
tracing::debug!("WebSocket read error, dropping connection");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = ping_interval.tick() => {
|
|
||||||
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
|
|
||||||
tracing::debug!("WebSocket ping failed, client is dead");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
"🔌 WebSocket connection closed for project: {:?}",
|
|
||||||
filter_project_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn websocket_ticket_hash_never_stores_raw_ticket() {
|
|
||||||
let ticket = "wst_example-ticket";
|
|
||||||
let hash = hash_ws_ticket(ticket);
|
|
||||||
|
|
||||||
assert_ne!(hash, ticket);
|
|
||||||
assert_eq!(hash.len(), 64);
|
|
||||||
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
|
|
||||||
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
|
|
||||||
|
|
||||||
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
|
|
||||||
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
|
|
||||||
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
|
|
||||||
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use backend_api::billing_rules::{
|
|
||||||
DEFAULT_OPERATIVE_K, DEFAULT_OPERATIVE_PBASE_COP, OperativeBillingInput,
|
|
||||||
calculate_operative_billing, calculate_operative_billing_with_defaults,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn expected_annual_cop(equivalent_tg: f64, pbase_cop: i64, k: f64) -> i64 {
|
|
||||||
(equivalent_tg * pbase_cop as f64 * equivalent_tg.powf(-k)).round() as i64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hf_counts_as_one_equivalent_tg_with_defaults() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(1, 0).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.hf_tags, 1);
|
|
||||||
assert_eq!(result.lf_tags, 0);
|
|
||||||
assert_eq!(result.equivalent_tg, 1.0);
|
|
||||||
assert_eq!(result.pbase_cop, DEFAULT_OPERATIVE_PBASE_COP);
|
|
||||||
assert_eq!(result.k, DEFAULT_OPERATIVE_K);
|
|
||||||
assert_eq!(result.annual_cop, 110_000);
|
|
||||||
assert_eq!(result.monthly_cop, 9_167);
|
|
||||||
assert_eq!(result.effective_hf_price_cop, 110_000);
|
|
||||||
assert_eq!(result.effective_lf_price_cop, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lf_counts_as_zero_point_four_equivalent_tg() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(0, 1).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.hf_tags, 0);
|
|
||||||
assert_eq!(result.lf_tags, 1);
|
|
||||||
assert_eq!(result.equivalent_tg, 0.4);
|
|
||||||
assert_eq!(
|
|
||||||
result.annual_cop,
|
|
||||||
expected_annual_cop(0.4, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
|
|
||||||
);
|
|
||||||
assert_eq!(result.effective_hf_price_cop, 0);
|
|
||||||
assert_eq!(result.effective_lf_price_cop, result.annual_cop);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mixed_usage_exposes_equivalent_tg_and_not_flat_total_tags_pricing() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(1, 1).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.equivalent_tg, 1.4);
|
|
||||||
assert_eq!(
|
|
||||||
result.annual_cop,
|
|
||||||
expected_annual_cop(1.4, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
|
|
||||||
);
|
|
||||||
assert_ne!(result.annual_cop, 2 * DEFAULT_OPERATIVE_PBASE_COP);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn volume_discount_uses_v_to_the_negative_k_factor() {
|
|
||||||
let one_tg = calculate_operative_billing_with_defaults(1, 0).expect("valid usage");
|
|
||||||
let ten_tg = calculate_operative_billing_with_defaults(10, 0).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(ten_tg.equivalent_tg, 10.0);
|
|
||||||
assert_eq!(
|
|
||||||
ten_tg.annual_cop,
|
|
||||||
expected_annual_cop(10.0, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
|
|
||||||
);
|
|
||||||
assert!(ten_tg.effective_hf_price_cop < one_tg.effective_hf_price_cop);
|
|
||||||
assert_ne!(ten_tg.annual_cop, 10 * DEFAULT_OPERATIVE_PBASE_COP);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_usage_returns_zero_without_applying_power_formula() {
|
|
||||||
let result = calculate_operative_billing_with_defaults(0, 0).expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.equivalent_tg, 0.0);
|
|
||||||
assert_eq!(result.annual_cop, 0);
|
|
||||||
assert_eq!(result.monthly_cop, 0);
|
|
||||||
assert_eq!(result.effective_hf_price_cop, 0);
|
|
||||||
assert_eq!(result.effective_lf_price_cop, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_defaults_drive_effective_prices_until_profile_values_are_stored() {
|
|
||||||
let result = calculate_operative_billing(OperativeBillingInput {
|
|
||||||
hf_tags: 2,
|
|
||||||
lf_tags: 5,
|
|
||||||
pbase_cop: 200_000,
|
|
||||||
k: 0.20,
|
|
||||||
})
|
|
||||||
.expect("valid usage");
|
|
||||||
|
|
||||||
assert_eq!(result.equivalent_tg, 4.0);
|
|
||||||
assert_eq!(result.annual_cop, expected_annual_cop(4.0, 200_000, 0.20));
|
|
||||||
assert_eq!(
|
|
||||||
result.effective_hf_price_cop,
|
|
||||||
(result.annual_cop as f64 / result.equivalent_tg).round() as i64
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.effective_lf_price_cop,
|
|
||||||
((result.annual_cop as f64 / result.equivalent_tg) * 0.4).round() as i64
|
|
||||||
);
|
|
||||||
assert_eq!(result.currency, "COP");
|
|
||||||
assert_eq!(result.rounding_policy, "nearest peso");
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,3 @@ hex = "0.4.3"
|
|||||||
aws-config = "1.5"
|
aws-config = "1.5"
|
||||||
aws-sdk-s3 = "1.50"
|
aws-sdk-s3 = "1.50"
|
||||||
reqwest = { workspace = true, features = ["json", "rustls", "stream"] }
|
reqwest = { workspace = true, features = ["json", "rustls", "stream"] }
|
||||||
axum = { workspace = true }
|
|
||||||
tower = { workspace = true }
|
|
||||||
prometheus = { workspace = true }
|
|
||||||
|
|||||||
@@ -51,13 +51,10 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin build-orchestrator
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
COPY . .
|
||||||
COPY --from=cacher /app /app
|
# Copiar caché del cacher
|
||||||
|
COPY --from=cacher /app/target target
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
|
||||||
COPY services/shared-lib services/shared-lib
|
|
||||||
COPY services/build-orchestrator services/build-orchestrator
|
|
||||||
RUN cargo build --release --bin build-orchestrator
|
RUN cargo build --release --bin build-orchestrator
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -558,10 +558,7 @@ pub async fn handle_project_created(
|
|||||||
pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let binary_path = std::path::Path::new("target/x86_64-unknown-linux-musl/release/edge-agent");
|
let binary_path = std::path::Path::new("target/x86_64-unknown-linux-musl/release/edge-agent");
|
||||||
if !binary_path.exists() {
|
if !binary_path.exists() {
|
||||||
tracing::warn!(
|
tracing::warn!("⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}", binary_path);
|
||||||
"⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}",
|
|
||||||
binary_path
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,12 +624,7 @@ pub async fn upload_generic_linux_binary() -> Result<(), Box<dyn std::error::Err
|
|||||||
let body = aws_sdk_s3::primitives::ByteStream::from_path(binary_path).await?;
|
let body = aws_sdk_s3::primitives::ByteStream::from_path(binary_path).await?;
|
||||||
let key = "agents/linux/x86_64/edge-agent".to_string();
|
let key = "agents/linux/x86_64/edge-agent".to_string();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!("🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...", version, bucket_name, key);
|
||||||
"🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...",
|
|
||||||
version,
|
|
||||||
bucket_name,
|
|
||||||
key
|
|
||||||
);
|
|
||||||
client
|
client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(&bucket_name)
|
.bucket(&bucket_name)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
mod installer_worker;
|
mod installer_worker;
|
||||||
mod metrics;
|
|
||||||
mod publisher;
|
mod publisher;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -7,8 +6,6 @@ use uuid::Uuid;
|
|||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created";
|
const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created";
|
||||||
@@ -128,40 +125,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "info,build_orchestrator=debug".into()),
|
.unwrap_or_else(|_| "info,build_orchestrator=debug".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer().json().flatten_event(false))
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// Metrics HTTP server
|
|
||||||
let metrics_app = axum::Router::new().route(
|
|
||||||
"/metrics",
|
|
||||||
axum::routing::get(|| async {
|
|
||||||
use prometheus::TextEncoder;
|
|
||||||
let encoder = TextEncoder::new();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
encoder
|
|
||||||
.encode_utf8(&prometheus::gather(), &mut buffer)
|
|
||||||
.unwrap();
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::OK,
|
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
|
||||||
buffer,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let metrics_addr: SocketAddr = ([0, 0, 0, 0], 9094).into();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match tokio::net::TcpListener::bind(metrics_addr).await {
|
|
||||||
Ok(listener) => {
|
|
||||||
if let Err(e) = axum::serve(listener, metrics_app).await {
|
|
||||||
tracing::error!("Metrics HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to bind metrics endpoint on {}: {}", metrics_addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)...");
|
tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)...");
|
||||||
|
|
||||||
// Conectar a la base de datos con retry — necesario si el orquestador
|
// Conectar a la base de datos con retry — necesario si el orquestador
|
||||||
@@ -172,36 +138,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
loop {
|
loop {
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
match PgPoolOptions::new()
|
match PgPoolOptions::new()
|
||||||
.max_connections(
|
.max_connections(5)
|
||||||
env::var("DB_MAX_CONNECTIONS")
|
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(5),
|
|
||||||
)
|
|
||||||
.min_connections(
|
|
||||||
env::var("DB_MIN_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)
|
|
||||||
.idle_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(600),
|
|
||||||
))
|
|
||||||
.max_lifetime(Duration::from_secs(
|
|
||||||
env::var("DB_MAX_LIFETIME_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1800),
|
|
||||||
))
|
|
||||||
.acquire_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_ACQUIRE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
))
|
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -268,7 +206,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let state = state_worker.clone();
|
let state = state_worker.clone();
|
||||||
|
|
||||||
if topic == PROJECT_CREATED_TOPIC {
|
if topic == PROJECT_CREATED_TOPIC {
|
||||||
metrics::BUILDS_TRIGGERED.inc();
|
|
||||||
let payload_str = String::from_utf8_lossy(&payload);
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
||||||
if let Ok(event) = serde_json::from_str::<
|
if let Ok(event) = serde_json::from_str::<
|
||||||
@@ -284,14 +221,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Worker de instalador falló: {}", e);
|
tracing::error!("Worker de instalador falló: {}", e);
|
||||||
metrics::BUILDS_FAILED.inc();
|
|
||||||
} else {
|
|
||||||
metrics::BUILDS_COMPLETED.inc();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if topic.starts_with("omnioil/control/deploy/") {
|
} else if topic.starts_with("omnioil/control/deploy/") {
|
||||||
metrics::DEPLOYS_TRIGGERED.inc();
|
|
||||||
let payload_str = String::from_utf8_lossy(&payload);
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📥 Recibido Deployment Request. Payload crudo: {}",
|
"📥 Recibido Deployment Request. Payload crudo: {}",
|
||||||
@@ -317,9 +250,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Pipeline de build falló: {}", e);
|
tracing::error!("Pipeline de build falló: {}", e);
|
||||||
metrics::BUILDS_FAILED.inc();
|
|
||||||
} else {
|
|
||||||
metrics::BUILDS_COMPLETED.inc();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
use prometheus::{register_counter, Counter};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
pub static BUILDS_TRIGGERED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_builds_triggered_total",
|
|
||||||
"Total installer builds triggered"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static BUILDS_COMPLETED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_builds_completed_total",
|
|
||||||
"Total installer builds completed successfully"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static BUILDS_FAILED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_builds_failed_total",
|
|
||||||
"Total installer builds that failed"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static DEPLOYS_TRIGGERED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_orchestrator_deploys_triggered_total",
|
|
||||||
"Total deployments triggered"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
@@ -51,13 +51,10 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin data-collector
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
COPY . .
|
||||||
COPY --from=cacher /app /app
|
# Copiar caché del cacher
|
||||||
|
COPY --from=cacher /app/target target
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
|
||||||
COPY services/shared-lib services/shared-lib
|
|
||||||
COPY services/data-collector services/data-collector
|
|
||||||
RUN cargo build --release --bin data-collector
|
RUN cargo build --release --bin data-collector
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -23,25 +23,23 @@ pub async fn ingest_telemetry_batch(
|
|||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
// 2. Resolver Asset ID por Código (Cacheable en el futuro)
|
// 2. Resolver Asset ID por Código (Cacheable en el futuro)
|
||||||
let asset_opt = sqlx::query("SELECT id, project_id FROM edge_assets WHERE code = $1")
|
let asset_opt = sqlx::query("SELECT id FROM edge_assets WHERE code = $1")
|
||||||
.bind(asset_code)
|
.bind(asset_code)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(asset) = asset_opt {
|
if let Some(asset) = asset_opt {
|
||||||
let asset_id: uuid::Uuid = asset.get("id");
|
let asset_id: uuid::Uuid = asset.get("id");
|
||||||
let project_id: uuid::Uuid = asset.get("project_id");
|
|
||||||
|
|
||||||
// 3. Insertar Telemetría Raw
|
// 3. Insertar Telemetría Raw
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO telemetry_raw (time, project_id, asset_id, data, source)
|
INSERT INTO telemetry_raw (time, asset_id, data)
|
||||||
VALUES ($1, $2, $3, $4, 'SCADA')
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING
|
ON CONFLICT (time, asset_id) DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&time)
|
.bind(&time)
|
||||||
.bind(&project_id)
|
|
||||||
.bind(&asset_id)
|
.bind(&asset_id)
|
||||||
.bind(&data)
|
.bind(&data)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
|
|||||||
@@ -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",
|
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -78,14 +78,9 @@ ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
|
|||||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||||
ENV OPENSSL_NO_ASM=1
|
ENV OPENSSL_NO_ASM=1
|
||||||
|
|
||||||
# Copiar el esqueleto dummy desde el cacher (contiene todos los Cargo.toml del workspace)
|
COPY . .
|
||||||
COPY --from=cacher /app /app
|
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
|
||||||
COPY services/shared-lib services/shared-lib
|
|
||||||
COPY services/edge-agent services/edge-agent
|
|
||||||
|
|
||||||
# Parchear la versión del agente directamente en Cargo.toml antes de compilar.
|
# Parchear la versión del agente directamente en Cargo.toml antes de compilar.
|
||||||
ARG BUILD_VERSION=0.3.0
|
ARG BUILD_VERSION=0.3.0
|
||||||
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
|
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
|
||||||
@@ -128,7 +123,7 @@ COPY --from=builder /app/bin/linux/edge-agent /app/services/edge-agent/target/x8
|
|||||||
|
|
||||||
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
||||||
COPY ./services/edge-agent/wix /app/services/edge-agent/wix
|
COPY ./services/edge-agent/wix /app/services/edge-agent/wix
|
||||||
COPY ./services/edge-agent/*.ico /app/services/edge-agent/
|
COPY ./services/edge-agent/icon.ico /app/services/edge-agent/
|
||||||
COPY ./services/edge-agent/msi_worker.sh /app/services/edge-agent/msi_worker.sh
|
COPY ./services/edge-agent/msi_worker.sh /app/services/edge-agent/msi_worker.sh
|
||||||
|
|
||||||
RUN sed -i 's/\r$//' /app/services/edge-agent/msi_worker.sh && \
|
RUN sed -i 's/\r$//' /app/services/edge-agent/msi_worker.sh && \
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 229 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -6,200 +6,108 @@
|
|||||||
|
|
||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
#[cfg(windows)]
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tray_icon::{
|
||||||
|
TrayIcon, TrayIconBuilder,
|
||||||
|
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem},
|
||||||
|
};
|
||||||
|
use winit::application::ApplicationHandler;
|
||||||
|
use winit::event_loop::ActiveEventLoop;
|
||||||
|
use winit::event_loop::{ControlFlow, EventLoop};
|
||||||
|
|
||||||
|
// Protocolo compartido
|
||||||
#[path = "../ipc_protocol.rs"]
|
#[path = "../ipc_protocol.rs"]
|
||||||
mod ipc_protocol;
|
mod ipc_protocol;
|
||||||
|
use ipc_protocol::{AgentCommand, IpcMessage};
|
||||||
|
|
||||||
#[cfg(windows)]
|
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
||||||
mod app {
|
|
||||||
use super::ipc_protocol::{AgentCommand, IpcMessage};
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tray_icon::{
|
|
||||||
TrayIcon, TrayIconBuilder,
|
|
||||||
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem},
|
|
||||||
};
|
|
||||||
use winit::application::ApplicationHandler;
|
|
||||||
use winit::event_loop::ActiveEventLoop;
|
|
||||||
use winit::event_loop::{ControlFlow, EventLoop};
|
|
||||||
|
|
||||||
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
struct EdgeTrayApp {
|
||||||
|
tray_icon: Option<TrayIcon>,
|
||||||
|
quit_id: MenuId,
|
||||||
|
test_id: MenuId,
|
||||||
|
status_label: MenuItem,
|
||||||
|
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
|
||||||
|
status_rx: tokio::sync::mpsc::Receiver<String>,
|
||||||
|
}
|
||||||
|
|
||||||
struct EdgeTrayApp {
|
impl ApplicationHandler for EdgeTrayApp {
|
||||||
tray_icon: Option<TrayIcon>,
|
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
||||||
quit_id: MenuId,
|
|
||||||
test_id: MenuId,
|
fn window_event(
|
||||||
status_label: MenuItem,
|
&mut self,
|
||||||
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
|
_event_loop: &ActiveEventLoop,
|
||||||
status_rx: tokio::sync::mpsc::Receiver<IpcMessage>,
|
_window_id: winit::window::WindowId,
|
||||||
// Estados de animación y actualización
|
_event: winit::event::WindowEvent,
|
||||||
is_updating: bool,
|
) {
|
||||||
animation_tick: usize,
|
|
||||||
last_tick_time: std::time::Instant,
|
|
||||||
connected_mqtt: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationHandler for EdgeTrayApp {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
// Recibir actualizaciones de estado del backend
|
||||||
|
while let Ok(new_status) = self.status_rx.try_recv() {
|
||||||
fn window_event(
|
self.status_label.set_text(new_status);
|
||||||
&mut self,
|
|
||||||
_event_loop: &ActiveEventLoop,
|
|
||||||
_window_id: winit::window::WindowId,
|
|
||||||
_event: winit::event::WindowEvent,
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
// Revisar clicks en el menú
|
||||||
// Recibir actualizaciones de estado del backend
|
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||||
while let Ok(msg) = self.status_rx.try_recv() {
|
if event.id == self.quit_id {
|
||||||
match msg {
|
self.tray_icon.take();
|
||||||
IpcMessage::StatusUpdate {
|
event_loop.exit();
|
||||||
status,
|
} else if event.id == self.test_id {
|
||||||
connected_mqtt,
|
let _ = self.command_tx.try_send(AgentCommand::Notify(
|
||||||
current_version,
|
"¡Hola desde el Tray Icon!".to_string(),
|
||||||
} => {
|
));
|
||||||
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ú
|
|
||||||
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
|
||||||
if event.id == self.quit_id {
|
|
||||||
self.tray_icon.take();
|
|
||||||
event_loop.exit();
|
|
||||||
} else if event.id == self.test_id {
|
|
||||||
let _ = self.command_tx.try_send(AgentCommand::Notify(
|
|
||||||
"¡Hola desde el Tray Icon!".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() {
|
#[tokio::main]
|
||||||
let event_loop = EventLoop::new().unwrap();
|
async fn main() {
|
||||||
event_loop.set_control_flow(ControlFlow::Wait);
|
let event_loop = EventLoop::new().unwrap();
|
||||||
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
|
||||||
// Setup Tray Menu
|
// Setup Tray Menu
|
||||||
let tray_menu = Menu::new();
|
let tray_menu = Menu::new();
|
||||||
let status_label = MenuItem::new("Estado: Conectando...", false, None);
|
let status_label = MenuItem::new("Estado: Conectando...", false, None);
|
||||||
let test_i = MenuItem::new("Probar Notificación", true, None);
|
let test_i = MenuItem::new("Probar Notificación", true, None);
|
||||||
let quit_i = MenuItem::new("Salir", true, None);
|
let quit_i = MenuItem::new("Salir", true, None);
|
||||||
|
|
||||||
let quit_id = quit_i.id().clone();
|
let quit_id = quit_i.id().clone();
|
||||||
let test_id = test_i.id().clone();
|
let test_id = test_i.id().clone();
|
||||||
|
|
||||||
tray_menu
|
tray_menu
|
||||||
.append_items(&[
|
.append_items(&[
|
||||||
&status_label,
|
&status_label,
|
||||||
&test_i,
|
&test_i,
|
||||||
&PredefinedMenuItem::separator(),
|
&PredefinedMenuItem::separator(),
|
||||||
&quit_i,
|
&quit_i,
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let icon_path = std::env::current_exe()
|
let icon_path = std::env::current_exe()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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,145 +124,58 @@ 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut app = EdgeTrayApp {
|
|
||||||
tray_icon: Some(tray_icon),
|
|
||||||
quit_id,
|
|
||||||
test_id,
|
|
||||||
status_label,
|
|
||||||
command_tx,
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_icon_or_default(path: &std::path::Path, connected: bool) -> tray_icon::Icon {
|
|
||||||
if path.exists() {
|
|
||||||
if let Ok(image) = image::open(path) {
|
|
||||||
let image = image.into_rgba8();
|
|
||||||
let (width, height) = image.dimensions();
|
|
||||||
let rgba = image.into_raw();
|
|
||||||
if let Ok(icon) = tray_icon::Icon::from_rgba(rgba, width, height) {
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Generar icono de estado elegante de 16x16 en memoria
|
let mut app = EdgeTrayApp {
|
||||||
let width = 16;
|
tray_icon: Some(tray_icon),
|
||||||
let height = 16;
|
quit_id,
|
||||||
let mut rgba = vec![0u8; (width * height * 4) as usize];
|
test_id,
|
||||||
for y in 0..height {
|
status_label,
|
||||||
for x in 0..width {
|
command_tx,
|
||||||
let idx = ((y * width + x) * 4) as usize;
|
status_rx,
|
||||||
|
};
|
||||||
|
|
||||||
// Punto de estado en la parte inferior derecha: x: 11..14, y: 11..14
|
event_loop.run_app(&mut app).unwrap();
|
||||||
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)]
|
fn load_icon(path: &std::path::Path) -> tray_icon::Icon {
|
||||||
#[tokio::main]
|
let (icon_rgba, icon_width, icon_height) = {
|
||||||
async fn main() {
|
let image = image::open(path)
|
||||||
app::run();
|
.expect("Failed to open icon path")
|
||||||
}
|
.into_rgba8();
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
#[cfg(not(windows))]
|
let rgba = image.into_raw();
|
||||||
fn main() {
|
(rgba, width, height)
|
||||||
println!("System tray companion is only supported on Windows.");
|
};
|
||||||
|
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -138,15 +71,12 @@ fn set_secret_permissions(path: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── DPAPI (Windows only) ────────────────────────────────────────────────────
|
// ─── DPAPI (Windows only) ────────────────────────────────────────────────────
|
||||||
// Usa la definición correcta de CRYPT_INTEGER_BLOB directamente desde windows_sys
|
|
||||||
// para garantizar el alineamiento correcto en x86_64. La struct manual anterior
|
|
||||||
// podía causar corrupción de stack (0xc0000409) por padding incorrecto.
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -154,34 +84,31 @@ mod dpapi {
|
|||||||
fn LocalFree(hmem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
|
fn LocalFree(hmem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
|
#[repr(C)]
|
||||||
// Usar catch_unwind para capturar cualquier abort nativo y convertirlo
|
struct DataBlob {
|
||||||
// en un error manejable en lugar de matar el proceso completo.
|
cb_data: u32,
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
pb_data: *mut u8,
|
||||||
protect_inner(data)
|
|
||||||
}))
|
|
||||||
.unwrap_or_else(|_| Err(anyhow!("CryptProtectData causó un panic/abort — los datos de entrada pueden estar corruptos")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protect_inner(data: &[u8]) -> Result<Vec<u8>> {
|
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
let mut input = CRYPT_INTEGER_BLOB {
|
let input = DataBlob {
|
||||||
cbData: data.len() as u32,
|
cb_data: data.len() as u32,
|
||||||
pbData: data.as_ptr() as *mut u8,
|
pb_data: data.as_ptr() as *mut u8,
|
||||||
};
|
};
|
||||||
let mut output = CRYPT_INTEGER_BLOB {
|
let mut output = DataBlob {
|
||||||
cbData: 0,
|
cb_data: 0,
|
||||||
pbData: std::ptr::null_mut(),
|
pb_data: std::ptr::null_mut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
CryptProtectData(
|
CryptProtectData(
|
||||||
&mut input as *mut _,
|
&input as *const DataBlob as *const _,
|
||||||
|
std::ptr::null(),
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null(),
|
||||||
std::ptr::null_mut(),
|
|
||||||
CRYPTPROTECT_LOCAL_MACHINE,
|
CRYPTPROTECT_LOCAL_MACHINE,
|
||||||
&mut output as *mut _,
|
&mut output as *mut DataBlob as *mut _,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,37 +119,30 @@ mod dpapi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
|
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() };
|
||||||
unsafe { LocalFree(output.pbData as *mut std::ffi::c_void) };
|
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
|
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let input = DataBlob {
|
||||||
unprotect_inner(data)
|
cb_data: data.len() as u32,
|
||||||
}))
|
pb_data: data.as_ptr() as *mut u8,
|
||||||
.unwrap_or_else(|_| Err(anyhow!("CryptUnprotectData causó un panic/abort — los datos cifrados pueden estar corruptos")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unprotect_inner(data: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
let mut input = CRYPT_INTEGER_BLOB {
|
|
||||||
cbData: data.len() as u32,
|
|
||||||
pbData: data.as_ptr() as *mut u8,
|
|
||||||
};
|
};
|
||||||
let mut output = CRYPT_INTEGER_BLOB {
|
let mut output = DataBlob {
|
||||||
cbData: 0,
|
cb_data: 0,
|
||||||
pbData: std::ptr::null_mut(),
|
pb_data: std::ptr::null_mut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
CryptUnprotectData(
|
CryptUnprotectData(
|
||||||
&mut input as *mut _,
|
&input as *const DataBlob as *const _,
|
||||||
std::ptr::null_mut(),
|
|
||||||
std::ptr::null_mut(),
|
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
0,
|
0,
|
||||||
&mut output as *mut _,
|
&mut output as *mut DataBlob as *mut _,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,8 +153,8 @@ mod dpapi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
|
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() };
|
||||||
unsafe { LocalFree(output.pbData as *mut std::ffi::c_void) };
|
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -115,38 +61,13 @@ impl DataCollectorManager {
|
|||||||
|
|
||||||
for asset_cfg in assets {
|
for asset_cfg in assets {
|
||||||
for device_cfg in asset_cfg.devices {
|
for device_cfg in asset_cfg.devices {
|
||||||
|
let mqtt = self.mqtt_client.clone();
|
||||||
|
let topic = telemetry_topic.clone();
|
||||||
|
let storage_clone = self.storage.clone();
|
||||||
|
let project_id = self.project_id;
|
||||||
let device = device_cfg.device;
|
let device = device_cfg.device;
|
||||||
let reporter = log_reporter.clone();
|
let reporter = log_reporter.clone();
|
||||||
|
|
||||||
// Verificar protocolo soportado ANTES de crear la tarea.
|
|
||||||
// Protocolos sin driver implementado no deben generar JoinHandles fantasma.
|
|
||||||
match device.protocol {
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::Mqtt => {
|
|
||||||
let msg = format!(
|
|
||||||
"⚠️ Dispositivo '{}' usa protocolo MQTT genérico, que no está soportado. \
|
|
||||||
Usa MQTT_SPARKPLUG_B para recolección de datos, o envía datos \
|
|
||||||
directamente al Data Gateway HTTP (puerto 8081).",
|
|
||||||
device.name
|
|
||||||
);
|
|
||||||
tracing::warn!("{}", msg);
|
|
||||||
let _ = reporter.warn("COLLECTOR", &msg).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::EthernetIp
|
|
||||||
| shared_lib::edge_models::devices::DeviceProtocol::Profinet
|
|
||||||
| shared_lib::edge_models::devices::DeviceProtocol::ModbusRtu => {
|
|
||||||
let msg = format!(
|
|
||||||
"⚠️ Dispositivo '{}' usa protocolo {:?}, que aún no tiene driver implementado. \
|
|
||||||
Protocolos soportados: MODBUS_TCP, S7, OPC_UA, MQTT_SPARKPLUG_B, SQL_DB.",
|
|
||||||
device.name, device.protocol
|
|
||||||
);
|
|
||||||
tracing::warn!("{}", msg);
|
|
||||||
let _ = reporter.warn("COLLECTOR", &msg).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {} // Protocolo soportado, continuar
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,25 +75,240 @@ 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(),
|
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
||||||
variables,
|
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
||||||
mqtt: self.mqtt_client.clone(),
|
let unit_id = device
|
||||||
topic: telemetry_topic.clone(),
|
.protocol_config
|
||||||
storage: self.storage.clone(),
|
.get("unit_id")
|
||||||
project_id: self.project_id,
|
.and_then(|v| v.as_u64())
|
||||||
deadband_pct: self.deadband_threshold_pct,
|
.unwrap_or(1) as u8;
|
||||||
reporter: reporter.clone(),
|
tracing::debug!(
|
||||||
};
|
"🔧 Initializing Modbus driver for {} at {}:{}",
|
||||||
|
device.name,
|
||||||
|
device.host,
|
||||||
|
device.port
|
||||||
|
);
|
||||||
|
Box::new(ModbusTcpDriver::new(
|
||||||
|
&device.host,
|
||||||
|
device.port as u16,
|
||||||
|
unit_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
|
||||||
|
let rack = device
|
||||||
|
.protocol_config
|
||||||
|
.get("rack")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
let slot = device
|
||||||
|
.protocol_config
|
||||||
|
.get("slot")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(1) as u16;
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing S7 driver for {} at {}:{}",
|
||||||
|
device.name,
|
||||||
|
device.host,
|
||||||
|
device.port
|
||||||
|
);
|
||||||
|
Box::new(S7CommDriver::new(&device.host, rack, slot))
|
||||||
|
}
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
|
||||||
|
let endpoint = device
|
||||||
|
.protocol_config
|
||||||
|
.get("endpoint")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&device.host)
|
||||||
|
.to_string();
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing OPC-UA driver for {} at {}",
|
||||||
|
device.name,
|
||||||
|
endpoint
|
||||||
|
);
|
||||||
|
Box::new(OpcUaDriver::new(&endpoint))
|
||||||
|
}
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::MqttSparkplugB => {
|
||||||
|
let group_id = device
|
||||||
|
.protocol_config
|
||||||
|
.get("group_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let edge_node_id = device
|
||||||
|
.protocol_config
|
||||||
|
.get("edge_node_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing MQTT Sparkplug B driver for {} at {}:{} (Group: {}, Node: {})",
|
||||||
|
device.name,
|
||||||
|
device.host,
|
||||||
|
device.port,
|
||||||
|
group_id,
|
||||||
|
edge_node_id
|
||||||
|
);
|
||||||
|
Box::new(MqttSparkplugBDriver::new(
|
||||||
|
&device.host,
|
||||||
|
device.port as u16,
|
||||||
|
group_id,
|
||||||
|
edge_node_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::SqlDb => {
|
||||||
|
let conn_str = device
|
||||||
|
.protocol_config
|
||||||
|
.get("connection_string")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
tracing::debug!(
|
||||||
|
"🔧 Initializing SQL Database driver for {} with connection string: {}",
|
||||||
|
device.name,
|
||||||
|
conn_str
|
||||||
|
);
|
||||||
|
Box::new(SqlDbDriver::new(conn_str))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let msg = format!(
|
||||||
|
"Unsupported protocol for device {}: {:?}",
|
||||||
|
device.name, device.protocol
|
||||||
|
);
|
||||||
|
tracing::error!("{}", msg);
|
||||||
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let handle = tokio::spawn(run_device_collector(spec.clone()));
|
let mut deadband = DeadbandFilter::new(deadband_pct);
|
||||||
handles.push((handle, spec));
|
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
||||||
|
let mut connected_notified = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
timer.tick().await;
|
||||||
|
|
||||||
|
if !driver.is_alive() {
|
||||||
|
connected_notified = false;
|
||||||
|
let msg = format!(
|
||||||
|
"🔌 Attempting to connect to {} at {}:{}...",
|
||||||
|
device.name, device.host, device.port
|
||||||
|
);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
|
if let Err(e) = driver.connect().await {
|
||||||
|
let msg =
|
||||||
|
format!("❌ Connection failed for {}: {}", device.name, e);
|
||||||
|
tracing::warn!("{}", msg);
|
||||||
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connected_notified {
|
||||||
|
let msg = format!(
|
||||||
|
"✅ Successfully connected to device '{}' at {}:{}",
|
||||||
|
device.name, device.host, device.port
|
||||||
|
);
|
||||||
|
tracing::info!("{}", msg);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
connected_notified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = format!("📡 Reading variables for {}...", device.name);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
|
match driver.read_variables(&variables).await {
|
||||||
|
Ok(values) => {
|
||||||
|
if !values.is_empty() {
|
||||||
|
let msg = format!(
|
||||||
|
"📥 Read successful for {}: {} values obtained",
|
||||||
|
device.name,
|
||||||
|
values.len()
|
||||||
|
);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
|
// Apply deadband filter: suppress numeric values that haven't
|
||||||
|
// changed beyond the configured threshold percentage.
|
||||||
|
let filtered: HashMap<String, Value> = values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(key, val)| {
|
||||||
|
match val.as_f64() {
|
||||||
|
Some(f) => deadband.should_publish(key, f),
|
||||||
|
None => true, // non-numeric values always publish
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
"🔇 All values suppressed by deadband filter for {}",
|
||||||
|
device.name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let payload = json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"device_name": device.name,
|
||||||
|
"values": filtered,
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload_str = payload.to_string();
|
||||||
|
let payload_bytes = payload_str.as_bytes().to_vec();
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"📤 Publishing telemetry for {} to MQTT topic: {}",
|
||||||
|
device.name, topic
|
||||||
|
);
|
||||||
|
let _ = reporter.info("MQTT", &msg).await;
|
||||||
|
|
||||||
|
if let Err(e) = mqtt
|
||||||
|
.publish(&topic, QoS::AtMostOnce, false, payload_bytes)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let msg = format!(
|
||||||
|
"⚠️ MQTT publish failed for {}: {}. Buffering locally.",
|
||||||
|
device.name, e
|
||||||
|
);
|
||||||
|
let _ = reporter.error("MQTT", &msg).await;
|
||||||
|
let _ = storage_clone.save(&topic, &payload_str);
|
||||||
|
} else {
|
||||||
|
let _ = reporter
|
||||||
|
.info(
|
||||||
|
"MQTT",
|
||||||
|
&format!("✅ Data sent for {}", device.name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = reporter
|
||||||
|
.warn(
|
||||||
|
"COLLECTOR",
|
||||||
|
&format!(
|
||||||
|
"⚠️ No values returned for device {}",
|
||||||
|
device.name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg =
|
||||||
|
format!("❌ Error reading from device {}: {}", device.name, e);
|
||||||
|
tracing::error!("{}", msg);
|
||||||
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
let _ = driver.disconnect().await;
|
||||||
|
connected_notified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,282 +320,3 @@ impl DataCollectorManager {
|
|||||||
let _ = log_reporter.info("DEPLOY", &msg).await;
|
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 {
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
|
||||||
let unit_id = device
|
|
||||||
.protocol_config
|
|
||||||
.get("unit_id")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(1) as u8;
|
|
||||||
tracing::debug!(
|
|
||||||
"🔧 Initializing Modbus driver for {} at {}:{}",
|
|
||||||
device.name,
|
|
||||||
device.host,
|
|
||||||
device.port
|
|
||||||
);
|
|
||||||
Box::new(ModbusTcpDriver::new(
|
|
||||||
&device.host,
|
|
||||||
device.port as u16,
|
|
||||||
unit_id,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
|
|
||||||
let rack = device
|
|
||||||
.protocol_config
|
|
||||||
.get("rack")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0) as u16;
|
|
||||||
let slot = device
|
|
||||||
.protocol_config
|
|
||||||
.get("slot")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(1) as u16;
|
|
||||||
tracing::debug!(
|
|
||||||
"🔧 Initializing S7 driver for {} at {}:{}",
|
|
||||||
device.name,
|
|
||||||
device.host,
|
|
||||||
device.port
|
|
||||||
);
|
|
||||||
Box::new(S7CommDriver::new(&device.host, rack, slot))
|
|
||||||
}
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
|
|
||||||
let endpoint = device
|
|
||||||
.protocol_config
|
|
||||||
.get("endpoint")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(&device.host)
|
|
||||||
.to_string();
|
|
||||||
tracing::debug!(
|
|
||||||
"🔧 Initializing OPC-UA driver for {} at {}",
|
|
||||||
device.name,
|
|
||||||
endpoint
|
|
||||||
);
|
|
||||||
Box::new(OpcUaDriver::new(&endpoint))
|
|
||||||
}
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::MqttSparkplugB => {
|
|
||||||
let group_id = device
|
|
||||||
.protocol_config
|
|
||||||
.get("group_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let edge_node_id = device
|
|
||||||
.protocol_config
|
|
||||||
.get("edge_node_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
tracing::debug!(
|
|
||||||
"🔧 Initializing MQTT Sparkplug B driver for {} at {}:{} (Group: {}, Node: {})",
|
|
||||||
device.name,
|
|
||||||
device.host,
|
|
||||||
device.port,
|
|
||||||
group_id,
|
|
||||||
edge_node_id
|
|
||||||
);
|
|
||||||
Box::new(MqttSparkplugBDriver::new(
|
|
||||||
&device.host,
|
|
||||||
device.port as u16,
|
|
||||||
group_id,
|
|
||||||
edge_node_id,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::SqlDb => {
|
|
||||||
let conn_str = device
|
|
||||||
.protocol_config
|
|
||||||
.get("connection_string")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
tracing::debug!(
|
|
||||||
"🔧 Initializing SQL Database driver for {} with connection string: {}",
|
|
||||||
device.name,
|
|
||||||
conn_str
|
|
||||||
);
|
|
||||||
Box::new(SqlDbDriver::new(conn_str))
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
// Este branch no debería alcanzarse porque filtramos arriba,
|
|
||||||
// pero lo dejamos como safety net.
|
|
||||||
tracing::error!(
|
|
||||||
"❌ Protocol {:?} reached driver init for device {} — this is a bug.",
|
|
||||||
other,
|
|
||||||
device.name
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut deadband = DeadbandFilter::new(deadband_pct);
|
|
||||||
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
|
||||||
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 {
|
|
||||||
timer.tick().await;
|
|
||||||
|
|
||||||
if !driver.is_alive() {
|
|
||||||
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!(
|
|
||||||
"🔌 Attempting to connect to {} at {}:{}...",
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = driver.connect().await {
|
|
||||||
consecutive_connect_failures += 1;
|
|
||||||
let msg = format!(
|
|
||||||
"❌ Connection failed for {} (attempt #{}): {}",
|
|
||||||
device.name, consecutive_connect_failures, e
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incrementar backoff: 5s → 10s → 20s → 40s → 60s → 300s
|
|
||||||
reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conexión exitosa: resetear backoff
|
|
||||||
consecutive_connect_failures = 0;
|
|
||||||
reconnect_delay = Duration::from_secs(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !connected_notified {
|
|
||||||
let msg = format!(
|
|
||||||
"✅ Successfully connected to device '{}' at {}:{}",
|
|
||||||
device.name, device.host, device.port
|
|
||||||
);
|
|
||||||
tracing::info!("{}", msg);
|
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
|
||||||
connected_notified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = format!("📡 Reading variables for {}...", device.name);
|
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
|
||||||
|
|
||||||
match driver.read_variables(&variables).await {
|
|
||||||
Ok(values) => {
|
|
||||||
if !values.is_empty() {
|
|
||||||
let msg = format!(
|
|
||||||
"📥 Read successful for {}: {} values obtained",
|
|
||||||
device.name,
|
|
||||||
values.len()
|
|
||||||
);
|
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
|
||||||
|
|
||||||
// Apply deadband filter: suppress numeric values that haven't
|
|
||||||
// changed beyond the configured threshold percentage.
|
|
||||||
let filtered: HashMap<String, Value> = values
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(key, val)| {
|
|
||||||
match val.as_f64() {
|
|
||||||
Some(f) => deadband.should_publish(key, f),
|
|
||||||
None => true, // non-numeric values always publish
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if filtered.is_empty() {
|
|
||||||
tracing::debug!(
|
|
||||||
"🔇 All values suppressed by deadband filter for {}",
|
|
||||||
device.name
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let payload = json!({
|
|
||||||
"project_id": project_id,
|
|
||||||
"device_name": device.name,
|
|
||||||
"values": filtered,
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let payload_str = payload.to_string();
|
|
||||||
let payload_bytes = payload_str.as_bytes().to_vec();
|
|
||||||
|
|
||||||
let msg = format!(
|
|
||||||
"📤 Publishing telemetry for {} to MQTT topic: {}",
|
|
||||||
device.name, topic
|
|
||||||
);
|
|
||||||
let _ = reporter.info("MQTT", &msg).await;
|
|
||||||
|
|
||||||
if let Err(e) = mqtt
|
|
||||||
.publish(&topic, QoS::AtMostOnce, false, payload_bytes)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let msg = format!(
|
|
||||||
"⚠️ MQTT publish failed for {}: {}. Buffering locally.",
|
|
||||||
device.name, e
|
|
||||||
);
|
|
||||||
let _ = reporter.error("MQTT", &msg).await;
|
|
||||||
let _ = storage.save(&topic, &payload_str).await;
|
|
||||||
} else {
|
|
||||||
let _ = reporter
|
|
||||||
.info(
|
|
||||||
"MQTT",
|
|
||||||
&format!("✅ Data sent for {}", device.name),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = reporter
|
|
||||||
.warn(
|
|
||||||
"COLLECTOR",
|
|
||||||
&format!(
|
|
||||||
"⚠️ No values returned for device {}",
|
|
||||||
device.name
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg =
|
|
||||||
format!("❌ Error reading from device {}: {}", device.name, e);
|
|
||||||
tracing::error!("{}", msg);
|
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
|
||||||
let _ = driver.disconnect().await;
|
|
||||||
connected_notified = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -15,7 +14,6 @@ use tokio::time::{Duration, interval};
|
|||||||
/// Estado compartido para el gateway de datos.
|
/// Estado compartido para el gateway de datos.
|
||||||
struct GatewayState {
|
struct GatewayState {
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
project_id: uuid::Uuid,
|
|
||||||
telemetry_topic: String,
|
telemetry_topic: String,
|
||||||
storage: Arc<StoreAndForward>,
|
storage: Arc<StoreAndForward>,
|
||||||
last_heartbeat: Arc<AtomicI64>,
|
last_heartbeat: Arc<AtomicI64>,
|
||||||
@@ -32,7 +30,6 @@ pub async fn start_data_gateway(
|
|||||||
|
|
||||||
let state = Arc::new(GatewayState {
|
let state = Arc::new(GatewayState {
|
||||||
mqtt_client: mqtt_client.clone(),
|
mqtt_client: mqtt_client.clone(),
|
||||||
project_id,
|
|
||||||
telemetry_topic: format!("omnioil/telemetry/{}", project_id),
|
telemetry_topic: format!("omnioil/telemetry/{}", project_id),
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
last_heartbeat: last_heartbeat.clone(),
|
last_heartbeat: last_heartbeat.clone(),
|
||||||
@@ -73,13 +70,16 @@ async fn ingest_data(
|
|||||||
) -> Json<Value> {
|
) -> Json<Value> {
|
||||||
let topic = &state.telemetry_topic;
|
let topic = &state.telemetry_topic;
|
||||||
|
|
||||||
if let Err(e) = normalize_telemetry_payload(&mut payload, state.project_id) {
|
// Asegurar que el payload tenga un timestamp de captura (Capture Time)
|
||||||
tracing::warn!(
|
// Si Node-RED no lo envió, lo generamos en el momento justo de recibirlo en el borde.
|
||||||
project_id = %state.project_id,
|
if payload.get("timestamp").is_none() {
|
||||||
error = %e,
|
if let Some(obj) = payload.as_object_mut() {
|
||||||
"Data Gateway rejected invalid SCADA telemetry payload"
|
obj.insert(
|
||||||
);
|
"timestamp".to_string(),
|
||||||
return Json(json!({ "status": "error", "message": e }));
|
json!(chrono::Utc::now().to_rfc3339()),
|
||||||
|
);
|
||||||
|
obj.insert("is_agent_timestamped".to_string(), json!(true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let data_str = match serde_json::to_string(&payload) {
|
let data_str = match serde_json::to_string(&payload) {
|
||||||
@@ -122,7 +122,7 @@ async fn ingest_data(
|
|||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
// Error inmediato de MQTT
|
// Error inmediato de MQTT
|
||||||
tracing::warn!("⚠️ MQTT error. Saving to local storage: {}", e);
|
tracing::warn!("⚠️ MQTT error. Saving to local storage: {}", e);
|
||||||
if let Err(err) = state.storage.save(topic, &data_str).await {
|
if let Err(err) = state.storage.save(topic, &data_str) {
|
||||||
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
||||||
Json(json!({ "status": "error", "message": "Storage failure" }))
|
Json(json!({ "status": "error", "message": "Storage failure" }))
|
||||||
} else {
|
} else {
|
||||||
@@ -132,7 +132,7 @@ async fn ingest_data(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Timeout (Buffer lleno o red colgada)
|
// Timeout (Buffer lleno o red colgada)
|
||||||
tracing::warn!("⏳ MQTT Timeout. Saving to local storage to unblock.");
|
tracing::warn!("⏳ MQTT Timeout. Saving to local storage to unblock.");
|
||||||
if let Err(err) = state.storage.save(topic, &data_str).await {
|
if let Err(err) = state.storage.save(topic, &data_str) {
|
||||||
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
||||||
Json(json!({ "status": "error", "message": "Storage failure" }))
|
Json(json!({ "status": "error", "message": "Storage failure" }))
|
||||||
} else {
|
} else {
|
||||||
@@ -142,66 +142,6 @@ async fn ingest_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_telemetry_payload(
|
|
||||||
payload: &mut Value,
|
|
||||||
authoritative_project_id: uuid::Uuid,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let obj = payload
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or_else(|| "payload must be a JSON object".to_string())?;
|
|
||||||
|
|
||||||
match obj.get("project_id") {
|
|
||||||
Some(Value::String(project_id)) => {
|
|
||||||
let payload_project_id = uuid::Uuid::parse_str(project_id)
|
|
||||||
.map_err(|_| "project_id must be a valid UUID string".to_string())?;
|
|
||||||
|
|
||||||
if payload_project_id != authoritative_project_id {
|
|
||||||
return Err(format!(
|
|
||||||
"project_id mismatch: payload project_id {} does not match gateway project_id {}",
|
|
||||||
payload_project_id, authoritative_project_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(_) => return Err("project_id must be a UUID string when present".to_string()),
|
|
||||||
None => {
|
|
||||||
obj.insert(
|
|
||||||
"project_id".to_string(),
|
|
||||||
json!(authoritative_project_id.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match obj.get("device_name") {
|
|
||||||
Some(Value::String(device_name)) if !device_name.trim().is_empty() => {}
|
|
||||||
Some(Value::String(_)) => return Err("device_name must not be empty".to_string()),
|
|
||||||
Some(_) => return Err("device_name must be a string".to_string()),
|
|
||||||
None => return Err("device_name is required".to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
match obj.get("values") {
|
|
||||||
Some(Value::Object(_)) => {}
|
|
||||||
Some(_) => return Err("values must be a JSON object".to_string()),
|
|
||||||
None => return Err("values is required".to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
match obj.get("timestamp") {
|
|
||||||
Some(Value::String(timestamp)) => {
|
|
||||||
chrono::DateTime::parse_from_rfc3339(timestamp)
|
|
||||||
.map_err(|_| "timestamp must be a valid RFC3339 datetime string".to_string())?;
|
|
||||||
}
|
|
||||||
Some(_) => return Err("timestamp must be a string".to_string()),
|
|
||||||
None => {
|
|
||||||
obj.insert(
|
|
||||||
"timestamp".to_string(),
|
|
||||||
json!(chrono::Utc::now().to_rfc3339()),
|
|
||||||
);
|
|
||||||
obj.insert("is_agent_timestamped".to_string(), json!(true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handler para watchdog (Heartbeat desde el contenedor).
|
/// Handler para watchdog (Heartbeat desde el contenedor).
|
||||||
async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value> {
|
async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value> {
|
||||||
state
|
state
|
||||||
@@ -209,105 +149,3 @@ async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value>
|
|||||||
.store(chrono::Utc::now().timestamp(), Ordering::SeqCst);
|
.store(chrono::Utc::now().timestamp(), Ordering::SeqCst);
|
||||||
Json(json!({ "status": "alive" }))
|
Json(json!({ "status": "alive" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_adds_authoritative_project_and_agent_timestamp_when_missing() {
|
|
||||||
let project_id = uuid::Uuid::new_v4();
|
|
||||||
let mut payload = json!({
|
|
||||||
"device_name": "node-red-pump-1",
|
|
||||||
"values": { "pressure": 42.0 }
|
|
||||||
});
|
|
||||||
|
|
||||||
normalize_telemetry_payload(&mut payload, project_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(payload["project_id"], project_id.to_string());
|
|
||||||
assert_eq!(payload["device_name"], "node-red-pump-1");
|
|
||||||
assert_eq!(payload["values"]["pressure"], 42.0);
|
|
||||||
assert!(payload.get("timestamp").and_then(Value::as_str).is_some());
|
|
||||||
assert_eq!(payload["is_agent_timestamped"], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_rejects_payload_project_mismatch() {
|
|
||||||
let project_id = uuid::Uuid::new_v4();
|
|
||||||
let mut payload = json!({
|
|
||||||
"project_id": uuid::Uuid::new_v4().to_string(),
|
|
||||||
"device_name": "node-red-pump-1",
|
|
||||||
"values": { "pressure": 42.0 },
|
|
||||||
"timestamp": "2026-06-27T00:00:00Z"
|
|
||||||
});
|
|
||||||
|
|
||||||
let err = normalize_telemetry_payload(&mut payload, project_id).unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.contains("project_id mismatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_requires_device_name_and_values_object() {
|
|
||||||
let project_id = uuid::Uuid::new_v4();
|
|
||||||
|
|
||||||
let mut missing_device = json!({ "values": { "pressure": 42.0 } });
|
|
||||||
assert_eq!(
|
|
||||||
normalize_telemetry_payload(&mut missing_device, project_id).unwrap_err(),
|
|
||||||
"device_name is required"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut invalid_values = json!({
|
|
||||||
"device_name": "node-red-pump-1",
|
|
||||||
"values": 42.0
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
normalize_telemetry_payload(&mut invalid_values, project_id).unwrap_err(),
|
|
||||||
"values must be a JSON object"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_preserves_existing_timestamp_without_agent_flag() {
|
|
||||||
let project_id = uuid::Uuid::new_v4();
|
|
||||||
let timestamp = "2026-06-27T00:00:00Z";
|
|
||||||
let mut payload = json!({
|
|
||||||
"project_id": project_id.to_string(),
|
|
||||||
"device_name": "node-red-pump-1",
|
|
||||||
"values": { "pressure": 42.0 },
|
|
||||||
"timestamp": timestamp
|
|
||||||
});
|
|
||||||
|
|
||||||
normalize_telemetry_payload(&mut payload, project_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(payload["timestamp"], timestamp);
|
|
||||||
assert!(payload.get("is_agent_timestamped").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_rejects_invalid_timestamp() {
|
|
||||||
let project_id = uuid::Uuid::new_v4();
|
|
||||||
let mut payload = json!({
|
|
||||||
"project_id": project_id.to_string(),
|
|
||||||
"device_name": "node-red-pump-1",
|
|
||||||
"values": { "pressure": 42.0 },
|
|
||||||
"timestamp": 123
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
normalize_telemetry_payload(&mut payload, project_id).unwrap_err(),
|
|
||||||
"timestamp must be a string"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut payload = json!({
|
|
||||||
"project_id": project_id.to_string(),
|
|
||||||
"device_name": "node-red-pump-1",
|
|
||||||
"values": { "pressure": 42.0 },
|
|
||||||
"timestamp": "not-a-date"
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
normalize_telemetry_payload(&mut payload, project_id).unwrap_err(),
|
|
||||||
"timestamp must be a valid RFC3339 datetime string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
100.0
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
((new_val - entry.last_value) / entry.last_value.abs() * 100.0).abs()
|
((new_val - last) / last.abs() * 100.0).abs()
|
||||||
};
|
};
|
||||||
|
|
||||||
let time_elapsed = entry.last_published_at.elapsed().as_secs();
|
if change_pct > self.threshold_pct {
|
||||||
let force_by_time = time_elapsed >= self.max_silence_secs;
|
self.last_values.insert(key.to_string(), new_val);
|
||||||
|
|
||||||
if change_pct > self.threshold_pct || force_by_time {
|
|
||||||
if force_by_time && change_pct <= self.threshold_pct {
|
|
||||||
tracing::trace!(
|
|
||||||
"⏰ Deadband forced publish for '{}' after {}s of silence (value unchanged: {})",
|
|
||||||
key,
|
|
||||||
time_elapsed,
|
|
||||||
new_val
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self.entries.insert(
|
|
||||||
key.to_string(),
|
|
||||||
DeadbandEntry {
|
|
||||||
last_value: new_val,
|
|
||||||
last_published_at: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
@@ -122,16 +77,4 @@ mod tests {
|
|||||||
f.should_publish("level", 0.0);
|
f.should_publish("level", 0.0);
|
||||||
assert!(f.should_publish("level", 1.0));
|
assert!(f.should_publish("level", 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn forced_publish_after_silence() {
|
|
||||||
let mut f = DeadbandFilter::new(0.5);
|
|
||||||
// Configurar max_silence a 0 para el test
|
|
||||||
f.max_silence_secs = 0;
|
|
||||||
f.should_publish("pressure", 100.0);
|
|
||||||
// Esperar un instante para que elapsed > 0
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
// 0.01% change — below threshold but should force due to time
|
|
||||||
assert!(f.should_publish("pressure", 100.01));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ pub struct ModbusTcpDriver {
|
|||||||
port: u16,
|
port: u16,
|
||||||
unit_id: u8,
|
unit_id: u8,
|
||||||
ctx: Option<Arc<Mutex<Context>>>,
|
ctx: Option<Arc<Mutex<Context>>>,
|
||||||
/// Contador de errores consecutivos de transporte para detectar conexiones muertas.
|
|
||||||
consecutive_transport_errors: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModbusTcpDriver {
|
impl ModbusTcpDriver {
|
||||||
@@ -24,7 +22,6 @@ impl ModbusTcpDriver {
|
|||||||
port,
|
port,
|
||||||
unit_id,
|
unit_id,
|
||||||
ctx: None,
|
ctx: None,
|
||||||
consecutive_transport_errors: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,23 +30,8 @@ impl ModbusTcpDriver {
|
|||||||
impl ProtocolDriver for ModbusTcpDriver {
|
impl ProtocolDriver for ModbusTcpDriver {
|
||||||
async fn connect(&mut self) -> anyhow::Result<()> {
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
||||||
|
let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?;
|
||||||
// Timeout de 10s para la conexión TCP — evita bloqueo si el PLC está apagado
|
|
||||||
let ctx = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
tcp::connect_slave(socket_addr, Slave(self.unit_id)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Modbus TCP connection timeout (10s) for {}:{}",
|
|
||||||
self.host,
|
|
||||||
self.port
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
|
|
||||||
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
||||||
self.consecutive_transport_errors = 0;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"✅ Connected to Modbus TCP device at {}:{}",
|
"✅ Connected to Modbus TCP device at {}:{}",
|
||||||
self.host,
|
self.host,
|
||||||
@@ -60,13 +42,9 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
|
|
||||||
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
self.ctx = None;
|
self.ctx = None;
|
||||||
self.consecutive_transport_errors = 0;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lee variables Modbus con timeout de 5s por lectura individual.
|
|
||||||
/// Si un PLC acepta conexión TCP pero no responde a queries, la lectura
|
|
||||||
/// se cuelga indefinidamente sin timeout.
|
|
||||||
async fn read_variables(
|
async fn read_variables(
|
||||||
&mut self,
|
&mut self,
|
||||||
variables: &HashMap<String, String>,
|
variables: &HashMap<String, String>,
|
||||||
@@ -77,7 +55,6 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
let mut ctx = ctx_mutex.lock().await;
|
let mut ctx = ctx_mutex.lock().await;
|
||||||
let mut results = HashMap::new();
|
let mut results = HashMap::new();
|
||||||
let mut had_transport_error = false;
|
|
||||||
|
|
||||||
for (alias, address) in variables {
|
for (alias, address) in variables {
|
||||||
let (area, addr_str) = if address.contains(':') {
|
let (area, addr_str) = if address.contains(':') {
|
||||||
@@ -88,66 +65,33 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let addr: u16 = addr_str.parse().unwrap_or(0);
|
let addr: u16 = addr_str.parse().unwrap_or(0);
|
||||||
|
match area {
|
||||||
// Timeout de 5s por lectura individual — evita que un PLC inactivo congele la tarea
|
"HR" => match ctx.read_holding_registers(addr, 1).await {
|
||||||
let read_result = match area {
|
Ok(Ok(registers)) => {
|
||||||
"HR" => {
|
if let Some(v) = registers.first() {
|
||||||
tokio::time::timeout(
|
results.insert(alias.clone(), json!(v));
|
||||||
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() {
|
|
||||||
results.insert(alias.clone(), json!(v));
|
|
||||||
}
|
}
|
||||||
}
|
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
|
||||||
Ok(Ok(Err(e))) => {
|
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
|
||||||
tracing::warn!("Modbus {} Exception: {} for addr {}", area, e, addr);
|
},
|
||||||
}
|
"IR" => match ctx.read_input_registers(addr, 1).await {
|
||||||
Ok(Err(e)) => {
|
Ok(Ok(registers)) => {
|
||||||
tracing::warn!("Modbus {} Transport error: {} for addr {}", area, e, addr);
|
if let Some(v) = registers.first() {
|
||||||
had_transport_error = true;
|
results.insert(alias.clone(), json!(v));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
}
|
||||||
tracing::warn!(
|
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
|
||||||
"Modbus {} read timeout (5s) for addr {} — PLC may be unresponsive",
|
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
|
||||||
area,
|
},
|
||||||
addr
|
_ => tracing::warn!("Unsupported Modbus area: {}", area),
|
||||||
);
|
|
||||||
had_transport_error = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if had_transport_error {
|
|
||||||
self.consecutive_transport_errors += 1;
|
|
||||||
} else {
|
|
||||||
self.consecutive_transport_errors = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica si la conexión está activa Y funcional.
|
|
||||||
/// Si ha habido 3+ errores de transporte consecutivos, la conexión se
|
|
||||||
/// considera muerta y se fuerza reconexión.
|
|
||||||
fn is_alive(&self) -> bool {
|
fn is_alive(&self) -> bool {
|
||||||
self.ctx.is_some() && self.consecutive_transport_errors < 3
|
self.ctx.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ pub struct S7CommDriver {
|
|||||||
rack: u16,
|
rack: u16,
|
||||||
slot: u16,
|
slot: u16,
|
||||||
client: Option<Client<tcp::Transport>>,
|
client: Option<Client<tcp::Transport>>,
|
||||||
/// Contador de errores consecutivos para detectar conexiones muertas.
|
|
||||||
consecutive_errors: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl S7CommDriver {
|
impl S7CommDriver {
|
||||||
@@ -24,124 +22,58 @@ 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.
|
self.client = Some(client);
|
||||||
// El timeout evita que un PLC inalcanzable congele el hilo indefinidamente.
|
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
||||||
let result = tokio::time::timeout(
|
Ok(())
|
||||||
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.consecutive_errors = 0;
|
|
||||||
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
|
||||||
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 mut results = HashMap::new();
|
||||||
|
|
||||||
let vars_clone: Vec<(String, String)> = variables
|
for (alias, address) in variables {
|
||||||
.iter()
|
if address.starts_with("DB") {
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
let parts: Vec<&str> = address[2..].split('.').collect();
|
||||||
.collect();
|
if parts.len() == 2 {
|
||||||
|
let db_number: i32 = parts[0].parse().unwrap_or(1);
|
||||||
let read_result = tokio::time::timeout(
|
if parts[1].starts_with("DBW") {
|
||||||
std::time::Duration::from_secs(10),
|
let offset: i32 = parts[1][3..].parse().unwrap_or(0);
|
||||||
tokio::task::spawn_blocking(move || {
|
let mut buffer = vec![0u8; 2];
|
||||||
let mut results = HashMap::new();
|
if client.ag_read(db_number, offset, 2, &mut buffer).is_ok() {
|
||||||
let mut client = client;
|
let val = u16::from_be_bytes([buffer[0], buffer[1]]);
|
||||||
|
results.insert(alias.clone(), json!(val));
|
||||||
for (alias, address) in &vars_clone {
|
|
||||||
if address.starts_with("DB") {
|
|
||||||
let parts: Vec<&str> = address[2..].split('.').collect();
|
|
||||||
if parts.len() == 2 {
|
|
||||||
let db_number: i32 = parts[0].parse().unwrap_or(1);
|
|
||||||
if parts[1].starts_with("DBW") {
|
|
||||||
let offset: i32 = parts[1][3..].parse().unwrap_or(0);
|
|
||||||
let mut buffer = vec![0u8; 2];
|
|
||||||
if client.ag_read(db_number, offset, 2, &mut buffer).is_ok() {
|
|
||||||
let val = u16::from_be_bytes([buffer[0], buffer[1]]);
|
|
||||||
results.insert(alias.clone(), json!(val));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(client, results)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match read_result {
|
|
||||||
Ok(Ok((client, results))) => {
|
|
||||||
self.client = Some(client);
|
|
||||||
self.consecutive_errors = 0;
|
|
||||||
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"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica si el driver tiene una conexión activa Y no ha acumulado errores.
|
|
||||||
/// 3 errores consecutivos = conexión considerada muerta → fuerza reconexión.
|
|
||||||
fn is_alive(&self) -> bool {
|
fn is_alive(&self) -> bool {
|
||||||
self.client.is_some() && self.consecutive_errors < 3
|
self.client.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
||||||
//! Reporta métricas reales de CPU y memoria usando llamadas nativas del OS.
|
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
@@ -30,10 +29,6 @@ pub async fn start_health_checker(
|
|||||||
let mut timer = interval(check_interval);
|
let mut timer = interval(check_interval);
|
||||||
let mut json_buf = Vec::with_capacity(512);
|
let mut json_buf = Vec::with_capacity(512);
|
||||||
|
|
||||||
// Para cálculo de CPU: guardamos el snapshot anterior
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let mut prev_cpu = read_proc_stat();
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🏥 Native Health checker started (interval: {:?})",
|
"🏥 Native Health checker started (interval: {:?})",
|
||||||
check_interval
|
check_interval
|
||||||
@@ -44,41 +39,6 @@ pub async fn start_health_checker(
|
|||||||
|
|
||||||
let uptime = Utc::now().signed_duration_since(start_time).num_seconds() as u64;
|
let uptime = Utc::now().signed_duration_since(start_time).num_seconds() as u64;
|
||||||
|
|
||||||
// ─── Métricas de CPU ─────────────────────────────────────────────
|
|
||||||
let cpu_usage = {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let current = read_proc_stat();
|
|
||||||
let usage = calculate_cpu_percent(&prev_cpu, ¤t);
|
|
||||||
prev_cpu = current;
|
|
||||||
Some(usage)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
get_cpu_usage_windows()
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
||||||
{
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Métricas de Memoria ─────────────────────────────────────────
|
|
||||||
let memory_mb = {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
get_memory_usage_linux()
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
get_memory_usage_windows()
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
||||||
{
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Construir reporte nativo
|
// Construir reporte nativo
|
||||||
let report = HealthReport {
|
let report = HealthReport {
|
||||||
project_id: project_uuid,
|
project_id: project_uuid,
|
||||||
@@ -89,8 +49,8 @@ pub async fn start_health_checker(
|
|||||||
container_id: None, // Deprecated
|
container_id: None, // Deprecated
|
||||||
image_tag: None, // Deprecated
|
image_tag: None, // Deprecated
|
||||||
uptime_seconds: Some(uptime),
|
uptime_seconds: Some(uptime),
|
||||||
cpu_usage_percent: cpu_usage,
|
cpu_usage_percent: None,
|
||||||
memory_usage_mb: memory_mb.map(|m| m as f64),
|
memory_usage_mb: None,
|
||||||
restart_count: 0,
|
restart_count: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
@@ -106,130 +66,9 @@ pub async fn start_health_checker(
|
|||||||
let _ = mqtt.publish(&topic, QoS::AtMostOnce, false, payload).await;
|
let _ = mqtt.publish(&topic, QoS::AtMostOnce, false, payload).await;
|
||||||
});
|
});
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"📡 Native health report sent (CPU: {:?}%, RAM: {:?}MB, uptime: {}s)",
|
"📡 Native health report sent via MQTT for project: {}",
|
||||||
cpu_usage,
|
project_id
|
||||||
memory_mb,
|
|
||||||
uptime
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Linux: /proc/stat para CPU ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct CpuSnapshot {
|
|
||||||
total: u64,
|
|
||||||
idle: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn read_proc_stat() -> CpuSnapshot {
|
|
||||||
let content = std::fs::read_to_string("/proc/stat").unwrap_or_default();
|
|
||||||
let line = content.lines().next().unwrap_or("");
|
|
||||||
// cpu user nice system idle iowait irq softirq steal guest guest_nice
|
|
||||||
let values: Vec<u64> = line
|
|
||||||
.split_whitespace()
|
|
||||||
.skip(1) // skip "cpu"
|
|
||||||
.filter_map(|v| v.parse().ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let total: u64 = values.iter().sum();
|
|
||||||
let idle = values.get(3).copied().unwrap_or(0) + values.get(4).copied().unwrap_or(0);
|
|
||||||
|
|
||||||
CpuSnapshot { total, idle }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn calculate_cpu_percent(prev: &CpuSnapshot, current: &CpuSnapshot) -> f64 {
|
|
||||||
let total_diff = current.total.saturating_sub(prev.total);
|
|
||||||
let idle_diff = current.idle.saturating_sub(prev.idle);
|
|
||||||
|
|
||||||
if total_diff == 0 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = ((total_diff - idle_diff) as f64 / total_diff as f64) * 100.0;
|
|
||||||
(usage * 10.0).round() / 10.0 // Redondear a 1 decimal
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Linux: /proc/meminfo para RAM ───────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn get_memory_usage_linux() -> Option<u64> {
|
|
||||||
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
|
|
||||||
let mut total_kb: u64 = 0;
|
|
||||||
let mut available_kb: u64 = 0;
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
if let Some(val) = line.strip_prefix("MemTotal:") {
|
|
||||||
total_kb = val.trim().split_whitespace().next()?.parse().ok()?;
|
|
||||||
} else if let Some(val) = line.strip_prefix("MemAvailable:") {
|
|
||||||
available_kb = val.trim().split_whitespace().next()?.parse().ok()?;
|
|
||||||
}
|
|
||||||
if total_kb > 0 && available_kb > 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if total_kb > 0 {
|
|
||||||
Some((total_kb - available_kb) / 1024) // Convertir a MB
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Windows: GlobalMemoryStatusEx para RAM ──────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn get_memory_usage_windows() -> Option<u64> {
|
|
||||||
use std::mem;
|
|
||||||
#[repr(C)]
|
|
||||||
struct MEMORYSTATUSEX {
|
|
||||||
dw_length: u32,
|
|
||||||
dw_memory_load: u32,
|
|
||||||
ull_total_phys: u64,
|
|
||||||
ull_avail_phys: u64,
|
|
||||||
ull_total_page_file: u64,
|
|
||||||
ull_avail_page_file: u64,
|
|
||||||
ull_total_virtual: u64,
|
|
||||||
ull_avail_virtual: u64,
|
|
||||||
ull_avail_extended_virtual: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "system" {
|
|
||||||
fn GlobalMemoryStatusEx(lpBuffer: *mut MEMORYSTATUSEX) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut status: MEMORYSTATUSEX = unsafe { mem::zeroed() };
|
|
||||||
status.dw_length = mem::size_of::<MEMORYSTATUSEX>() as u32;
|
|
||||||
|
|
||||||
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
|
|
||||||
if ok != 0 {
|
|
||||||
let used_bytes = status.ull_total_phys - status.ull_avail_phys;
|
|
||||||
Some(used_bytes / (1024 * 1024)) // Convertir a MB
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn get_cpu_usage_windows() -> Option<f64> {
|
|
||||||
// Usar PowerShell para un snapshot rápido de CPU
|
|
||||||
let output = std::process::Command::new("powershell")
|
|
||||||
.args([
|
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
"(Get-CimInstance Win32_Processor).LoadPercentage",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
let s = String::from_utf8(output.stdout).ok()?;
|
|
||||||
s.trim().parse::<f64>().ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,18 +6,11 @@ pub enum IpcMessage {
|
|||||||
StatusUpdate {
|
StatusUpdate {
|
||||||
status: String,
|
status: String,
|
||||||
connected_mqtt: bool,
|
connected_mqtt: bool,
|
||||||
current_version: String,
|
|
||||||
},
|
},
|
||||||
Notification {
|
Notification {
|
||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
OtaProgress {
|
|
||||||
percentage: u8,
|
|
||||||
stage: String, // "Downloading", "Verifying", "Applying", "Finished", "Error"
|
|
||||||
target_version: String,
|
|
||||||
error_message: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tray -> Agent
|
// Tray -> Agent
|
||||||
Command(AgentCommand),
|
Command(AgentCommand),
|
||||||
|
|||||||
@@ -16,41 +16,17 @@ 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
|
let server = ServerOptions::new()
|
||||||
// Windows (SECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, raw pointers) se
|
.first_pipe_instance(true)
|
||||||
// dropean antes de cualquier .await, satisfaciendo el bound Send de
|
.create(pipe_name)?;
|
||||||
// tokio::spawn.
|
|
||||||
let server = match create_pipe_server(pipe_name) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"⚠️ IPC: No se pudo crear instancia del pipe '{}': {}. Reintentando en 5s...",
|
|
||||||
pipe_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Esperar cliente con timeout para no bloquear indefinidamente
|
// Esperar cliente
|
||||||
// si ningún tray icon se conecta.
|
server.connect().await?;
|
||||||
match server.connect().await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"⚠️ IPC: Error esperando cliente en pipe: {}. Reintentando...",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let receiver_clone = receiver.resubscribe();
|
let receiver_clone = receiver.resubscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_client(server, receiver_clone).await {
|
if let Err(e) = handle_client(server, receiver_clone).await {
|
||||||
tracing::warn!("⚠️ IPC client disconnected: {}", e);
|
tracing::error!("IPC client error: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,61 +40,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,
|
||||||
@@ -134,10 +55,7 @@ async fn handle_client(
|
|||||||
match msg {
|
match msg {
|
||||||
Ok(m) => {
|
Ok(m) => {
|
||||||
let json = serde_json::to_string(&m)? + "\n";
|
let json = serde_json::to_string(&m)? + "\n";
|
||||||
if let Err(e) = write_half.write_all(json.as_bytes()).await {
|
write_half.write_all(json.as_bytes()).await?;
|
||||||
tracing::debug!("IPC write error (client likely disconnected): {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => break,
|
_ => break,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,9 @@ impl LogReporter {
|
|||||||
let mqtt = self.mqtt_client.clone();
|
let mqtt = self.mqtt_client.clone();
|
||||||
let topic = self.log_topic.clone();
|
let topic = self.log_topic.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = mqtt
|
let _ = mqtt
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
|
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
tracing::trace!("Log reporter MQTT publish failed (best-effort): {}", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ use anyhow::{Context, Result};
|
|||||||
use config::AgentConfig;
|
use config::AgentConfig;
|
||||||
use log_reporter::LogReporter;
|
use log_reporter::LogReporter;
|
||||||
use mqtt_listener::AgentCommand;
|
use mqtt_listener::AgentCommand;
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -65,7 +64,6 @@ fn main() -> Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
// Registrar gancho de pánico para capturar cualquier crash en los archivos de log
|
// Registrar gancho de pánico para capturar cualquier crash en los archivos de log
|
||||||
// y hacer un intento best-effort de notificar al servidor vía MQTT.
|
|
||||||
std::panic::set_hook(Box::new(|panic_info| {
|
std::panic::set_hook(Box::new(|panic_info| {
|
||||||
let payload = panic_info.payload();
|
let payload = panic_info.payload();
|
||||||
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
let message = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||||
@@ -79,53 +77,7 @@ fn main() -> Result<()> {
|
|||||||
.location()
|
.location()
|
||||||
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||||
.unwrap_or_else(|| "ubicación desconocida".to_string());
|
.unwrap_or_else(|| "ubicación desconocida".to_string());
|
||||||
let crash_msg = format!("🚨 PÁNICO (CRASH) detectado en {}: {}", location, message);
|
tracing::error!("🚨 PÁNICO (CRASH) detectado en {}: {}", location, message);
|
||||||
tracing::error!("{}", crash_msg);
|
|
||||||
|
|
||||||
// Best-effort: intentar notificar el crash al servidor vía MQTT.
|
|
||||||
// Usamos un runtime efímero porque el panic hook es síncrono
|
|
||||||
// y el runtime principal puede estar corrupto.
|
|
||||||
if let Ok(project_id) = std::env::var("OMNIOIL_PROJECT_ID") {
|
|
||||||
let topic = format!("omnioil/logs/{}", project_id);
|
|
||||||
let log_payload = serde_json::json!({
|
|
||||||
"project_id": project_id,
|
|
||||||
"level": "FATAL",
|
|
||||||
"category": "CRASH",
|
|
||||||
"message": crash_msg,
|
|
||||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
|
||||||
});
|
|
||||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
let _ = rt.block_on(async {
|
|
||||||
// Intentar conectar rápido y enviar el crash log
|
|
||||||
if let Ok(mqtt_host) = std::env::var("OMNIOIL_MQTT_HOST") {
|
|
||||||
let port: u16 = std::env::var("OMNIOIL_MQTT_PORT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|p| p.parse().ok())
|
|
||||||
.unwrap_or(1883);
|
|
||||||
let opts = rumqttc::MqttOptions::new("crash-reporter", mqtt_host, port);
|
|
||||||
let (client, mut eventloop) = rumqttc::AsyncClient::new(opts, 4);
|
|
||||||
// Dar 2s para conectar y enviar
|
|
||||||
let _ = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(2),
|
|
||||||
async {
|
|
||||||
let _ = eventloop.poll().await; // ConnAck
|
|
||||||
let _ = client.publish(
|
|
||||||
&topic,
|
|
||||||
rumqttc::QoS::AtMostOnce,
|
|
||||||
false,
|
|
||||||
serde_json::to_vec(&log_payload).unwrap_or_default(),
|
|
||||||
).await;
|
|
||||||
let _ = eventloop.poll().await; // flush
|
|
||||||
}
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Esperar un momento para permitir que el appender no bloqueante escriba el log a disco
|
// Esperar un momento para permitir que el appender no bloqueante escriba el log a disco
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
}));
|
}));
|
||||||
@@ -249,18 +201,8 @@ pub async fn run_agent(
|
|||||||
mqtt_client.clone(),
|
mqtt_client.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Canal para comandos — capacidad 64 para absorber ráfagas de deploy signals
|
// Canal para comandos
|
||||||
// y comandos OTA sin bloquear al mqtt_listener.
|
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16);
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(64);
|
|
||||||
|
|
||||||
// Exportar project_id y MQTT host como env vars para el panic hook
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("OMNIOIL_PROJECT_ID", &config.agent.project_id);
|
|
||||||
std::env::set_var("OMNIOIL_MQTT_HOST", &config.mqtt.host);
|
|
||||||
if let Some(port) = config.mqtt.port {
|
|
||||||
std::env::set_var("OMNIOIL_MQTT_PORT", port.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iniciar el bucle de eventos MQTT
|
// Iniciar el bucle de eventos MQTT
|
||||||
let _mqtt_handle = mqtt_listener::start_mqtt_event_loop(
|
let _mqtt_handle = mqtt_listener::start_mqtt_event_loop(
|
||||||
@@ -316,13 +258,13 @@ pub async fn run_agent(
|
|||||||
let queue_storage = storage.clone();
|
let queue_storage = storage.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Limpieza inmediata al arrancar
|
// Limpieza inmediata al arrancar
|
||||||
let _ = queue_storage.cleanup_old_queue_entries(7).await;
|
let _ = queue_storage.cleanup_old_queue_entries(7);
|
||||||
|
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
|
||||||
interval.tick().await; // descarta el primer tick instantáneo
|
interval.tick().await; // descarta el primer tick instantáneo
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let _ = queue_storage.cleanup_old_queue_entries(7).await;
|
let _ = queue_storage.cleanup_old_queue_entries(7);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,62 +276,6 @@ pub async fn run_agent(
|
|||||||
health_checker::start_health_checker(health_mqtt, health_config, health_reporter).await;
|
health_checker::start_health_checker(health_mqtt, health_config, health_reporter).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Watchdog de auto-reinicio ───────────────────────────────────────────────
|
|
||||||
// Monitorea que el agente siga "vivo". Si no hay actividad durante
|
|
||||||
// WATCHDOG_TIMEOUT_SECS, el proceso se auto-termina con exit(1).
|
|
||||||
// El Service Control Manager de Windows (configurado con sc failure)
|
|
||||||
// reiniciará el servicio automáticamente.
|
|
||||||
let watchdog_reporter = log_reporter.clone();
|
|
||||||
let watchdog_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs(),
|
|
||||||
));
|
|
||||||
let watchdog_hb_writer = watchdog_heartbeat.clone();
|
|
||||||
|
|
||||||
// El health checker ya hace tick periódico — usamos un heartbeat atómico
|
|
||||||
// separado que se actualiza en el loop principal y en el event loop MQTT.
|
|
||||||
tokio::spawn({
|
|
||||||
let reporter = watchdog_reporter.clone();
|
|
||||||
async move {
|
|
||||||
const WATCHDOG_TIMEOUT_SECS: u64 = 300; // 5 minutos sin actividad = freeze
|
|
||||||
const WATCHDOG_CHECK_INTERVAL_SECS: u64 = 60;
|
|
||||||
|
|
||||||
let mut check_timer =
|
|
||||||
tokio::time::interval(std::time::Duration::from_secs(WATCHDOG_CHECK_INTERVAL_SECS));
|
|
||||||
|
|
||||||
loop {
|
|
||||||
check_timer.tick().await;
|
|
||||||
let last = watchdog_heartbeat.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let elapsed = now.saturating_sub(last);
|
|
||||||
|
|
||||||
if elapsed > WATCHDOG_TIMEOUT_SECS {
|
|
||||||
let msg = format!(
|
|
||||||
"🚨 WATCHDOG: No se detectó actividad en {}s (umbral: {}s). \
|
|
||||||
Forzando reinicio del proceso para recuperación automática.",
|
|
||||||
elapsed, WATCHDOG_TIMEOUT_SECS
|
|
||||||
);
|
|
||||||
tracing::error!("{}", msg);
|
|
||||||
reporter.error("WATCHDOG", &msg).await;
|
|
||||||
// Dar tiempo al log reporter para enviar el mensaje
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
||||||
std::process::exit(1);
|
|
||||||
} else if elapsed > WATCHDOG_TIMEOUT_SECS / 2 {
|
|
||||||
tracing::warn!(
|
|
||||||
"⚠️ WATCHDOG: Sin actividad hace {}s. Umbral de reinicio: {}s.",
|
|
||||||
elapsed,
|
|
||||||
WATCHDOG_TIMEOUT_SECS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Iniciar Data Gateway
|
// Iniciar Data Gateway
|
||||||
let gateway_mqtt = mqtt_client.clone();
|
let gateway_mqtt = mqtt_client.clone();
|
||||||
let gateway_storage = storage.clone();
|
let gateway_storage = storage.clone();
|
||||||
@@ -398,34 +284,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,21 +293,7 @@ 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
|
|
||||||
watchdog_hb_writer.store(
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs(),
|
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some(cmd) = cmd_rx.recv() => {
|
Some(cmd) = cmd_rx.recv() => {
|
||||||
match cmd {
|
match cmd {
|
||||||
@@ -469,7 +313,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 +331,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 +344,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 +398,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
|
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
|
||||||
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
|
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
|
||||||
//! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre.
|
//! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre.
|
||||||
//!
|
|
||||||
//! ⚠️ IMPORTANTE: Usa `tokio::sync::Mutex` (NO `std::sync::Mutex`) para evitar
|
|
||||||
//! deadlocks en el runtime async de Tokio. Si se usa un mutex síncrono dentro de
|
|
||||||
//! un contexto async, el executor se congela al intentar adquirir un lock que otro
|
|
||||||
//! task del mismo hilo ya tiene.
|
|
||||||
|
|
||||||
use aes_gcm::{
|
use aes_gcm::{
|
||||||
Aes256Gcm, Nonce,
|
Aes256Gcm, Nonce,
|
||||||
@@ -18,8 +13,9 @@ 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 std::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>,
|
||||||
@@ -29,48 +25,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 +59,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(
|
||||||
@@ -103,8 +80,7 @@ impl StoreAndForward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Guarda un payload ENCRIPTADO usando la conexión persistente.
|
/// Guarda un payload ENCRIPTADO usando la conexión persistente.
|
||||||
/// Ahora es async para usar tokio::sync::Mutex de forma segura.
|
pub fn save(&self, topic: &str, payload: &str) -> Result<()> {
|
||||||
pub async fn save(&self, topic: &str, payload: &str) -> Result<()> {
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
let mut nonce_bytes = [0u8; 12];
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
@@ -114,7 +90,10 @@ impl StoreAndForward {
|
|||||||
.encrypt(nonce, payload.as_bytes())
|
.encrypt(nonce, payload.as_bytes())
|
||||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
let conn = self.conn.lock().await;
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO telemetry_queue (topic, payload, nonce, created_at) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO telemetry_queue (topic, payload, nonce, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![topic, ciphertext, nonce_bytes.to_vec(), Utc::now().to_rfc3339()],
|
params![topic, ciphertext, nonce_bytes.to_vec(), Utc::now().to_rfc3339()],
|
||||||
@@ -122,15 +101,13 @@ impl StoreAndForward {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Desencripta y envía los datos pendientes vía MQTT.
|
/// Desencripta y envía los datos usando la conexión persistente.
|
||||||
///
|
|
||||||
/// ⚠️ CRÍTICO: El lock de la conexión SQLite se libera ANTES de hacer
|
|
||||||
/// operaciones async de MQTT (.await) para evitar deadlocks en Tokio.
|
|
||||||
/// El patrón es: lock → read → unlock → send MQTT → lock → delete → unlock.
|
|
||||||
pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> {
|
pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> {
|
||||||
// Fase 1: Leer filas de la cola (lock corto)
|
|
||||||
let rows = {
|
let rows = {
|
||||||
let conn = self.conn.lock().await;
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50",
|
"SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50",
|
||||||
)?;
|
)?;
|
||||||
@@ -142,10 +119,8 @@ impl StoreAndForward {
|
|||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect();
|
||||||
res
|
res
|
||||||
// ← conn lock se libera aquí al salir del bloque
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fase 2: Enviar a MQTT SIN el lock (operaciones async seguras)
|
|
||||||
let mut sent_count = 0;
|
let mut sent_count = 0;
|
||||||
let mut ids_to_delete = Vec::new();
|
let mut ids_to_delete = Vec::new();
|
||||||
|
|
||||||
@@ -170,9 +145,12 @@ impl StoreAndForward {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fase 3: Eliminar mensajes enviados (lock corto de nuevo)
|
// Batch delete all sent messages in a single transaction
|
||||||
if !ids_to_delete.is_empty() {
|
if !ids_to_delete.is_empty() {
|
||||||
let conn = self.conn.lock().await;
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
let placeholders: Vec<String> = ids_to_delete.iter().map(|_| "?".to_string()).collect();
|
let placeholders: Vec<String> = ids_to_delete.iter().map(|_| "?".to_string()).collect();
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"DELETE FROM telemetry_queue WHERE id IN ({})",
|
"DELETE FROM telemetry_queue WHERE id IN ({})",
|
||||||
@@ -194,9 +172,12 @@ impl StoreAndForward {
|
|||||||
///
|
///
|
||||||
/// Útil para evitar acumulación ilimitada durante outages prolongados de MQTT.
|
/// Útil para evitar acumulación ilimitada durante outages prolongados de MQTT.
|
||||||
/// La telemetría tan antigua ya no tiene utilidad operativa.
|
/// La telemetría tan antigua ya no tiene utilidad operativa.
|
||||||
pub async fn cleanup_old_queue_entries(&self, max_days: i64) -> Result<usize> {
|
pub fn cleanup_old_queue_entries(&self, max_days: i64) -> Result<usize> {
|
||||||
let cutoff = (Utc::now() - chrono::Duration::days(max_days)).to_rfc3339();
|
let cutoff = (Utc::now() - chrono::Duration::days(max_days)).to_rfc3339();
|
||||||
let conn = self.conn.lock().await;
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
let deleted = conn.execute(
|
let deleted = conn.execute(
|
||||||
"DELETE FROM telemetry_queue WHERE created_at < ?1",
|
"DELETE FROM telemetry_queue WHERE created_at < ?1",
|
||||||
params![cutoff],
|
params![cutoff],
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -134,50 +129,15 @@ pub fn install_service() -> Result<()> {
|
|||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
println!("✅ Service '{}' installed successfully.", SERVICE_NAME);
|
println!("✅ Service '{}' installed successfully.", SERVICE_NAME);
|
||||||
|
|
||||||
// Configurar el servicio para reiniciarse automáticamente tras CUALQUIER fallo.
|
// Configurar el servicio para reiniciarse automáticamente tras fallo
|
||||||
// reset= 86400 → reinicia el contador de fallos después de 24h sin fallos.
|
// Reiniciar tras 5s (primer fallo), 15s (segundo), 30s (posteriores)
|
||||||
// actions= → restart/5s (1er fallo), restart/15s (2do), restart/30s (posterior).
|
let _ = std::process::Command::new("sc")
|
||||||
let recovery_result = std::process::Command::new("sc")
|
|
||||||
.arg("failure")
|
.arg("failure")
|
||||||
.arg(SERVICE_NAME)
|
.arg(SERVICE_NAME)
|
||||||
.arg("reset= 86400")
|
.arg("reset= 0")
|
||||||
.arg("actions= restart/5000/restart/15000/restart/30000")
|
.arg("actions= restart/5000/restart/15000/restart/30000")
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
match recovery_result {
|
|
||||||
Ok(out) if out.status.success() => {
|
|
||||||
println!("✅ Recovery policy configured (restart on crash/freeze).");
|
|
||||||
}
|
|
||||||
Ok(out) => {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
|
||||||
println!("⚠️ Could not configure recovery policy: {}", err);
|
|
||||||
}
|
|
||||||
Err(e) => println!("⚠️ Could not run sc failure: {}", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
// failureflag = 1 → Windows también ejecuta las acciones de recuperación
|
|
||||||
// cuando el servicio sale con código de error NO-CERO (ej. exit(1) del watchdog
|
|
||||||
// o crash 0xc0000409). Sin este flag, Windows solo reinicia si el SCM
|
|
||||||
// detecta una terminación inesperada, no si el proceso llama exit(1).
|
|
||||||
let flag_result = std::process::Command::new("sc")
|
|
||||||
.arg("failureflag")
|
|
||||||
.arg(SERVICE_NAME)
|
|
||||||
.arg("1")
|
|
||||||
.output();
|
|
||||||
|
|
||||||
match flag_result {
|
|
||||||
Ok(out) if out.status.success() => {
|
|
||||||
println!("✅ Failure flag enabled (recover from exit(1) + crash).");
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
// sc failureflag no existe en versiones antiguas de Windows
|
|
||||||
println!(
|
|
||||||
"⚠️ sc failureflag not supported (older Windows). Recovery still works for crashes."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = std::process::Command::new("sc")
|
let _ = std::process::Command::new("sc")
|
||||||
.arg("start")
|
.arg("start")
|
||||||
.arg(SERVICE_NAME)
|
.arg(SERVICE_NAME)
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ use anyhow::{Context, Result, bail};
|
|||||||
use semver::Version;
|
use semver::Version;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use crate::ipc_protocol::IpcMessage;
|
|
||||||
|
|
||||||
/// Aplica una actualización OTA a partir de los datos recibidos vía MQTT.
|
/// Aplica una actualización OTA a partir de los datos recibidos vía MQTT.
|
||||||
///
|
///
|
||||||
@@ -39,7 +38,6 @@ use crate::ipc_protocol::IpcMessage;
|
|||||||
/// - `asset_type`: "msi" | "exe".
|
/// - `asset_type`: "msi" | "exe".
|
||||||
/// - `verify_signature`: si es false, omite la verificación del hash (solo para pruebas).
|
/// - `verify_signature`: si es false, omite la verificación del hash (solo para pruebas).
|
||||||
/// - `prefer_msi`: en Windows, instalar vía MSI en lugar de self-replace .exe.
|
/// - `prefer_msi`: en Windows, instalar vía MSI en lugar de self-replace .exe.
|
||||||
/// - `ipc_tx`: transmisor para reportar progreso de la actualización al Tray Icon.
|
|
||||||
pub async fn apply_ota_from_command(
|
pub async fn apply_ota_from_command(
|
||||||
target_version: &str,
|
target_version: &str,
|
||||||
download_url: &str,
|
download_url: &str,
|
||||||
@@ -47,7 +45,6 @@ pub async fn apply_ota_from_command(
|
|||||||
asset_type: &str,
|
asset_type: &str,
|
||||||
verify_signature: bool,
|
verify_signature: bool,
|
||||||
prefer_msi: bool,
|
prefer_msi: bool,
|
||||||
ipc_tx: Option<tokio::sync::broadcast::Sender<IpcMessage>>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let current_raw = env!("CARGO_PKG_VERSION");
|
let current_raw = env!("CARGO_PKG_VERSION");
|
||||||
let current = Version::parse(current_raw)
|
let current = Version::parse(current_raw)
|
||||||
@@ -75,99 +72,20 @@ pub async fn apply_ota_from_command(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enviar señal inicial de progreso
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Downloading".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.user_agent(format!("omnioil-edge-agent/{}", current_raw))
|
.user_agent(format!("omnioil-edge-agent/{}", current_raw))
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
.context("Error al construir cliente HTTP para descarga OTA")?;
|
.context("Error al construir cliente HTTP para descarga OTA")?;
|
||||||
|
|
||||||
let content = match download_asset(&client, download_url, target_version, ipc_tx.as_ref()).await {
|
let content = download_asset(&client, download_url).await?;
|
||||||
Ok(c) => c,
|
verify_sha256(&content, sha256, verify_signature)?;
|
||||||
Err(e) => {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Error".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: Some(format!("Descarga fallida: {}", e)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verificando SHA-256
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 80,
|
|
||||||
stage: "Verifying".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = verify_sha256(&content, sha256, verify_signature) {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Error".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: Some(format!("Fallo de verificación SHA256: {}", e)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aplicando actualización
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 90,
|
|
||||||
stage: "Applying".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let use_msi = cfg!(windows) && prefer_msi && asset_type == "msi";
|
let use_msi = cfg!(windows) && prefer_msi && asset_type == "msi";
|
||||||
let apply_res = if use_msi {
|
if use_msi {
|
||||||
apply_msi_update(content).await
|
apply_msi_update(content).await
|
||||||
} else {
|
} else {
|
||||||
apply_exe_update(content).await
|
apply_exe_update(content).await
|
||||||
};
|
|
||||||
|
|
||||||
match apply_res {
|
|
||||||
Ok(_) => {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 100,
|
|
||||||
stage: "Finished".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if let Some(ref tx) = ipc_tx {
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: 0,
|
|
||||||
stage: "Error".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: Some(format!("Fallo al aplicar actualización: {}", e)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +121,8 @@ fn verify_sha256(content: &[u8], expected: Option<&str>, verify: bool) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Descarga asset desde la URL pre-firmada de MinIO en chunks para reportar progreso.
|
/// Descarga asset desde la URL pre-firmada de MinIO.
|
||||||
async fn download_asset(
|
async fn download_asset(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
||||||
client: &reqwest::Client,
|
|
||||||
url: &str,
|
|
||||||
target_version: &str,
|
|
||||||
ipc_tx: Option<&tokio::sync::broadcast::Sender<IpcMessage>>,
|
|
||||||
) -> Result<Vec<u8>> {
|
|
||||||
info!("⬇️ Downloading from: {}", url);
|
info!("⬇️ Downloading from: {}", url);
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
@@ -225,39 +138,11 @@ async fn download_asset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_size = response.content_length().unwrap_or(0);
|
Ok(response
|
||||||
let mut downloaded: u64 = 0;
|
.bytes()
|
||||||
let mut content = Vec::with_capacity(total_size as usize);
|
.await
|
||||||
let mut last_reported_percentage = 0;
|
.context("Failed to read asset bytes")?
|
||||||
|
.to_vec())
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
|
|
||||||
while let Some(item) = stream.next().await {
|
|
||||||
let chunk = item.context("Error reading response bytes stream")?;
|
|
||||||
content.extend_from_slice(&chunk);
|
|
||||||
downloaded += chunk.len() as u64;
|
|
||||||
|
|
||||||
if total_size > 0 {
|
|
||||||
let percentage = ((downloaded as f64 / total_size as f64) * 100.0) as u8;
|
|
||||||
// Reportar en pasos de 5% para no saturar el canal de IPC
|
|
||||||
if percentage >= last_reported_percentage + 5 || percentage == 100 {
|
|
||||||
last_reported_percentage = percentage;
|
|
||||||
if let Some(tx) = ipc_tx {
|
|
||||||
// El progreso de descarga representará del 0% al 75% del total
|
|
||||||
let display_pct = (percentage as f64 * 0.75) as u8;
|
|
||||||
let _ = tx.send(IpcMessage::OtaProgress {
|
|
||||||
percentage: display_pct,
|
|
||||||
stage: "Downloading".to_string(),
|
|
||||||
target_version: target_version.to_string(),
|
|
||||||
error_message: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instala vía MSI (Windows). Descarga atómica: .tmp → rename → msiexec.
|
/// Instala vía MSI (Windows). Descarga atómica: .tmp → rename → msiexec.
|
||||||
@@ -314,6 +199,6 @@ async fn apply_exe_update(content: Vec<u8>) -> Result<()> {
|
|||||||
"✅ Binary replaced. Saliendo para que el Service Manager reinicie con el nuevo binario."
|
"✅ Binary replaced. Saliendo para que el Service Manager reinicie con el nuevo binario."
|
||||||
);
|
);
|
||||||
|
|
||||||
// exit(1) → Windows SCM (con failureflag=1) / systemd reinicia el servicio con el nuevo .exe.
|
// exit(0) → Windows SCM / systemd reinicia el servicio con el nuevo .exe.
|
||||||
std::process::exit(1);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,11 @@
|
|||||||
Compressed='yes'
|
Compressed='yes'
|
||||||
SummaryCodepage='1252' />
|
SummaryCodepage='1252' />
|
||||||
|
|
||||||
<MajorUpgrade DowngradeErrorMessage="Una versión más reciente de [ProductName] ya está instalada." />
|
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="Una versión más reciente de [ProductName] ya está instalada." />
|
||||||
<Media Id='1' Cabinet='Sample.cab' EmbedCab='yes' DiskPrompt="CD-ROM #1" />
|
<Media Id='1' Cabinet='Sample.cab' EmbedCab='yes' DiskPrompt="CD-ROM #1" />
|
||||||
<Property Id='DiskPrompt' Value="OmniOil Edge Agent Installation [1]" />
|
<Property Id='DiskPrompt' Value="OmniOil Edge Agent Installation [1]" />
|
||||||
|
|
||||||
<!-- Add custom icon for Add/Remove Programs (Control Panel) -->
|
|
||||||
<Icon Id="ProductIcon" SourceFile="icon_app.ico" />
|
|
||||||
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
|
|
||||||
|
|
||||||
<Directory Id='TARGETDIR' Name='SourceDir'>
|
<Directory Id='TARGETDIR' Name='SourceDir'>
|
||||||
<Directory Id='SystemFolder' />
|
|
||||||
<Directory Id='ProgramFiles64Folder' Name='PFiles'>
|
<Directory Id='ProgramFiles64Folder' Name='PFiles'>
|
||||||
<Directory Id='OmniOilFolder' Name='OmniOil'>
|
<Directory Id='OmniOilFolder' Name='OmniOil'>
|
||||||
<Directory Id='INSTALLDIR' Name='EdgeAgent'>
|
<Directory Id='INSTALLDIR' Name='EdgeAgent'>
|
||||||
@@ -40,12 +35,11 @@
|
|||||||
Type="ownProcess"
|
Type="ownProcess"
|
||||||
Name="OmniOilEdgeAgent"
|
Name="OmniOilEdgeAgent"
|
||||||
DisplayName="OmniOil Edge Agent"
|
DisplayName="OmniOil Edge Agent"
|
||||||
Description="Edge agent for OmniOil (v{{VERSION}})."
|
Description="Edge agent for OmniOil container orchestration (v{{VERSION}})."
|
||||||
Start="auto"
|
Start="auto"
|
||||||
Account="LocalSystem"
|
Account="LocalSystem"
|
||||||
ErrorControl="normal" />
|
ErrorControl="normal" />
|
||||||
<ServiceControl Id="StartService" Start="install" Stop="both" Remove="uninstall" Name="OmniOilEdgeAgent" Wait="yes" />
|
<ServiceControl Id="StartService" Start="install" Stop="both" Remove="uninstall" Name="OmniOilEdgeAgent" Wait="yes" />
|
||||||
|
|
||||||
</Component>
|
</Component>
|
||||||
|
|
||||||
<Component Id='TrayExecutable' Guid='*'>
|
<Component Id='TrayExecutable' Guid='*'>
|
||||||
@@ -84,12 +78,5 @@
|
|||||||
de despliegue o administracion remota.
|
de despliegue o administracion remota.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- Custom Action to close the Tray app before validating locks -->
|
|
||||||
<CustomAction Id="KillTray" Directory="SystemFolder" ExeCommand="taskkill /F /IM edge-tray.exe" Execute="immediate" Return="ignore" />
|
|
||||||
|
|
||||||
<InstallExecuteSequence>
|
|
||||||
<Custom Action="KillTray" Before="InstallValidate" />
|
|
||||||
</InstallExecuteSequence>
|
|
||||||
|
|
||||||
</Product>
|
</Product>
|
||||||
</Wix>
|
</Wix>
|
||||||
@@ -16,6 +16,3 @@ tracing = { workspace = true }
|
|||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
axum = { workspace = true }
|
|
||||||
tower = { workspace = true }
|
|
||||||
prometheus = { workspace = true }
|
|
||||||
|
|||||||
@@ -51,13 +51,10 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin events-relay
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
COPY . .
|
||||||
COPY --from=cacher /app /app
|
# Copiar caché del cacher
|
||||||
|
COPY --from=cacher /app/target target
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
|
||||||
COPY services/shared-lib services/shared-lib
|
|
||||||
COPY services/events-relay services/events-relay
|
|
||||||
RUN cargo build --release --bin events-relay
|
RUN cargo build --release --bin events-relay
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
//! de dominio entre microservicios a través de Redis Streams.
|
//! de dominio entre microservicios a través de Redis Streams.
|
||||||
|
|
||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
mod metrics;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -18,45 +16,7 @@ use tracing::{info, warn};
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt::init();
|
||||||
.json()
|
|
||||||
.flatten_event(false)
|
|
||||||
.with_env_filter(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| "info".into()),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// Metrics HTTP server
|
|
||||||
let metrics_app = axum::Router::new().route(
|
|
||||||
"/metrics",
|
|
||||||
axum::routing::get(|| async {
|
|
||||||
use prometheus::TextEncoder;
|
|
||||||
let encoder = TextEncoder::new();
|
|
||||||
let mut buffer = String::new();
|
|
||||||
encoder
|
|
||||||
.encode_utf8(&prometheus::gather(), &mut buffer)
|
|
||||||
.unwrap();
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::OK,
|
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
|
||||||
buffer,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let metrics_addr: SocketAddr = ([0, 0, 0, 0], 9092).into();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match tokio::net::TcpListener::bind(metrics_addr).await {
|
|
||||||
Ok(listener) => {
|
|
||||||
if let Err(e) = axum::serve(listener, metrics_app).await {
|
|
||||||
tracing::error!("Metrics HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to bind metrics endpoint on {}: {}", metrics_addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||||
@@ -65,36 +25,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Conexiones de infraestructura
|
// Conexiones de infraestructura
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(
|
.max_connections(5)
|
||||||
env::var("DB_MAX_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(5),
|
|
||||||
)
|
|
||||||
.min_connections(
|
|
||||||
env::var("DB_MIN_CONNECTIONS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)
|
|
||||||
.idle_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(600),
|
|
||||||
))
|
|
||||||
.max_lifetime(Duration::from_secs(
|
|
||||||
env::var("DB_MAX_LIFETIME_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1800),
|
|
||||||
))
|
|
||||||
.acquire_timeout(Duration::from_secs(
|
|
||||||
env::var("DB_ACQUIRE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(10),
|
|
||||||
))
|
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await?;
|
.await?;
|
||||||
info!("✅ Connected to PostgreSQL.");
|
info!("✅ Connected to PostgreSQL.");
|
||||||
@@ -128,14 +59,10 @@ async fn main() -> Result<()> {
|
|||||||
"📬 Procesando {} eventos pendientes...",
|
"📬 Procesando {} eventos pendientes...",
|
||||||
pending_events.len()
|
pending_events.len()
|
||||||
);
|
);
|
||||||
metrics::OUTBOX_BATCH_SIZE.set(pending_events.len() as f64);
|
|
||||||
|
|
||||||
for event in pending_events {
|
for event in pending_events {
|
||||||
if let Err(e) = dispatcher::dispatch_event(&pool, &mut redis_conn, event).await {
|
if let Err(e) = dispatcher::dispatch_event(&pool, &mut redis_conn, event).await {
|
||||||
warn!("⚠️ Despacho parcialmente fallido: {}. Reintentando...", e);
|
warn!("⚠️ Despacho parcialmente fallido: {}. Reintentando...", e);
|
||||||
metrics::OUTBOX_EVENTS_FAILED.inc();
|
|
||||||
} else {
|
|
||||||
metrics::OUTBOX_EVENTS_PROCESSED.inc();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
use prometheus::{register_counter, register_gauge, Counter, Gauge};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
pub static OUTBOX_EVENTS_PROCESSED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_relay_events_processed_total",
|
|
||||||
"Total outbox events successfully relayed"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static OUTBOX_EVENTS_FAILED: LazyLock<Counter> = LazyLock::new(|| {
|
|
||||||
register_counter!(
|
|
||||||
"omnioil_relay_events_failed_total",
|
|
||||||
"Total outbox events that failed to relay"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static OUTBOX_BATCH_SIZE: LazyLock<Gauge> = LazyLock::new(|| {
|
|
||||||
register_gauge!(
|
|
||||||
"omnioil_relay_batch_size",
|
|
||||||
"Current outbox batch size being processed"
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { act } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import SessionBootstrap from './SessionBootstrap'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
|
||||||
import { SessionCoordinator } from '@/lib/api/client'
|
|
||||||
|
|
||||||
vi.mock('@/lib/api/client', () => ({
|
|
||||||
SessionCoordinator: {
|
|
||||||
refresh: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const refreshSession = vi.mocked(SessionCoordinator.refresh)
|
|
||||||
|
|
||||||
describe('SessionBootstrap', () => {
|
|
||||||
let container: HTMLDivElement
|
|
||||||
let root: ReturnType<typeof createRoot>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
root = createRoot(container)
|
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
|
||||||
useAuthStore.persist.clearStorage()
|
|
||||||
refreshSession.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function waitForBootstrap() {
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
||||||
if (useAuthStore.getState().hasBootstrapped) return
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => root.unmount())
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refreshes from the HttpOnly cookie when user metadata is persisted but tokens are not in memory', async () => {
|
|
||||||
useAuthStore.setState({
|
|
||||||
accessToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
token: null,
|
|
||||||
user: { email: 'admin@omnioil.app', role: 'admin' },
|
|
||||||
projects: [{ id: 'project-1', name: 'Piloto' }],
|
|
||||||
activeProjectId: 'project-1',
|
|
||||||
hasBootstrapped: false,
|
|
||||||
isBootstrapping: false,
|
|
||||||
})
|
|
||||||
refreshSession.mockImplementation(async () => {
|
|
||||||
useAuthStore.getState().setSession({
|
|
||||||
accessToken: 'fresh-access-token',
|
|
||||||
refreshToken: null,
|
|
||||||
role: 'admin',
|
|
||||||
email: 'admin@omnioil.app',
|
|
||||||
projects: [{ id: 'project-1', name: 'Piloto' }],
|
|
||||||
})
|
|
||||||
return 'fresh-access-token'
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(<SessionBootstrap><div>App lista</div></SessionBootstrap>)
|
|
||||||
})
|
|
||||||
await waitForBootstrap()
|
|
||||||
|
|
||||||
expect(refreshSession).toHaveBeenCalledTimes(1)
|
|
||||||
expect(useAuthStore.getState().accessToken).toBe('fresh-access-token')
|
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true)
|
|
||||||
expect(container.textContent).toContain('App lista')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -9,14 +9,16 @@ interface SessionBootstrapProps {
|
|||||||
|
|
||||||
export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
|
const refreshToken = useAuthStore((state) => state.refreshToken)
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
const hasBootstrapped = useAuthStore((state) => state.hasBootstrapped)
|
const hasBootstrapped = useAuthStore((state) => state.hasBootstrapped)
|
||||||
|
const isBootstrapping = useAuthStore((state) => state.isBootstrapping)
|
||||||
const beginBootstrap = useAuthStore((state) => state.beginBootstrap)
|
const beginBootstrap = useAuthStore((state) => state.beginBootstrap)
|
||||||
const completeBootstrap = useAuthStore((state) => state.completeBootstrap)
|
const completeBootstrap = useAuthStore((state) => state.completeBootstrap)
|
||||||
const logout = useAuthStore((state) => state.logout)
|
const logout = useAuthStore((state) => state.logout)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasBootstrapped) {
|
if (hasBootstrapped || isBootstrapping) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +34,9 @@ export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.email) {
|
if (!refreshToken || !user?.email) {
|
||||||
if (user) {
|
if (user) {
|
||||||
logout('bootstrap-missing-user-email')
|
logout('bootstrap-missing-refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -60,8 +62,10 @@ export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
accessToken,
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
user,
|
user,
|
||||||
hasBootstrapped,
|
hasBootstrapped,
|
||||||
|
isBootstrapping,
|
||||||
beginBootstrap,
|
beginBootstrap,
|
||||||
completeBootstrap,
|
completeBootstrap,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -6,26 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
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 { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
|
|
||||||
|
|
||||||
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
||||||
getAlarms: vi.fn(async () => []),
|
getAlarms: vi.fn(async () => []),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
|
||||||
listProjects: vi.fn(async () => []),
|
|
||||||
getProjectFullConfig: vi.fn(async () => ({ project: { id: 'project-1', name: 'Bloque Norte' }, assets: [] })),
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function flushQueries() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAuthStore() {
|
function resetAuthStore() {
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
||||||
useAuthStore.persist.clearStorage()
|
useAuthStore.persist.clearStorage()
|
||||||
@@ -57,14 +42,6 @@ describe('AdminLayout visual shell', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
resetAuthStore()
|
resetAuthStore()
|
||||||
seedAdminSession()
|
seedAdminSession()
|
||||||
vi.mocked(listProjects).mockResolvedValue([
|
|
||||||
{ id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
|
||||||
{ id: 'project-2', name: 'Bloque Sur', client: 'Cliente', operator_name: 'Operador', contract_number: '002', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
|
||||||
])
|
|
||||||
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: [],
|
|
||||||
})
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
container = document.createElement('div')
|
container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
@@ -261,79 +238,6 @@ describe('AdminLayout visual shell', () => {
|
|||||||
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders real project wells under the Pozos sidebar group with active alarm badges', async () => {
|
|
||||||
vi.mocked(getAlarms).mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'alarm-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
asset_id: 'well-1',
|
|
||||||
variable_alias: 'presion',
|
|
||||||
alarm_type: 'ABOVE_MAX',
|
|
||||||
acknowledged: false,
|
|
||||||
timestamp: '2026-06-22T00:00:00Z',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
asset: {
|
|
||||||
id: 'tank-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
code: 'TK-01',
|
|
||||||
name: 'Tanque Norte',
|
|
||||||
asset_type: 'TANQUE',
|
|
||||||
is_active: true,
|
|
||||||
metadata: {},
|
|
||||||
is_collector_enabled: true,
|
|
||||||
created_at: '2026-06-22T00:00:00Z',
|
|
||||||
updated_at: '2026-06-22T00:00:00Z',
|
|
||||||
},
|
|
||||||
devices: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/app/admin/*" element={<AdminLayout />}>
|
|
||||||
<Route path="wells/:wellId" element={<div>Dashboard del pozo</div>} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushQueries()
|
|
||||||
|
|
||||||
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(container?.textContent).toContain('Pozo Norte')
|
|
||||||
})
|
|
||||||
expect(container?.textContent).toContain('Pozos')
|
|
||||||
expect(container?.textContent).not.toContain('Tanque Norte')
|
|
||||||
expect(container?.querySelector('a[href="/app/admin/wells/well-1"]')?.className).toContain('bg-[#24282e]')
|
|
||||||
expect(container?.textContent).toContain('1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restores the persisted collapsed desktop preference on render', async () => {
|
it('restores the persisted collapsed desktop preference on render', async () => {
|
||||||
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')
|
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ChevronDown,
|
ChevronDown
|
||||||
Gauge,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { cn } from '@/lib/utils/cn'
|
import { cn } from '@/lib/utils/cn'
|
||||||
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
|
import { listProjects } from '@/features/admin/projects/api/projects-api'
|
||||||
|
import { listAssets } from '@/features/admin/projects/api/wells-api'
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export default function AdminLayout() {
|
|||||||
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
||||||
})
|
})
|
||||||
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
|
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
|
||||||
const [isWellsNavOpen, setIsWellsNavOpen] = useState(true)
|
const [collapsedSidebarProjectIds, setCollapsedSidebarProjectIds] = useState<string[]>([])
|
||||||
|
|
||||||
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
||||||
|
|
||||||
@@ -112,19 +112,25 @@ export default function AdminLayout() {
|
|||||||
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
|
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
})
|
})
|
||||||
const sidebarProjects = sidebarProjectsQuery.data ?? []
|
|
||||||
const sidebarProjectId = activeProjectId ?? (sidebarProjects.length === 1 ? sidebarProjects[0]?.id : null)
|
|
||||||
const sidebarProjectConfigQuery = useQuery({
|
|
||||||
queryKey: ['admin-sidebar-project-config', sidebarProjectId],
|
|
||||||
queryFn: () => getProjectFullConfig(sidebarProjectId!),
|
|
||||||
enabled: !!accessToken && !!sidebarProjectId && ['admin', 'superadmin', 'auditor'].includes(user?.role || ''),
|
|
||||||
staleTime: 60000,
|
|
||||||
})
|
|
||||||
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
|
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
|
||||||
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
|
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
|
||||||
const sidebarWells = sidebarProjectConfigQuery.data?.assets
|
const sidebarProjects = sidebarProjectsQuery.data ?? []
|
||||||
.map(({ asset }) => asset)
|
const sidebarProjectWellsQuery = useQuery({
|
||||||
.filter((asset) => asset.asset_type === 'POZO') ?? []
|
queryKey: ['admin-sidebar-project-wells', sidebarProjects.map((project) => project.id)],
|
||||||
|
queryFn: async () => {
|
||||||
|
const assetsByProject = await Promise.all(
|
||||||
|
sidebarProjects.map(async (project) => ({
|
||||||
|
project,
|
||||||
|
wells: (await listAssets(project.id)).filter((asset) => asset.asset_type === 'POZO'),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
return assetsByProject
|
||||||
|
},
|
||||||
|
enabled: sidebarProjects.length > 0 && !isDesktopSidebarCollapsed,
|
||||||
|
staleTime: 60000,
|
||||||
|
})
|
||||||
|
const sidebarProjectWells = sidebarProjectWellsQuery.data ?? []
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -150,8 +156,7 @@ export default function AdminLayout() {
|
|||||||
const activeNavItem = filteredSections
|
const activeNavItem = filteredSections
|
||||||
.flatMap((section) => section.items)
|
.flatMap((section) => section.items)
|
||||||
.find((item) => isNavItemActive(item.to, location))
|
.find((item) => isNavItemActive(item.to, location))
|
||||||
const isWellRouteActive = location.pathname.startsWith('/app/admin/wells/')
|
const currentSectionLabel = activeNavItem?.label ?? 'Administración'
|
||||||
const currentSectionLabel = activeNavItem?.label ?? (isWellRouteActive ? 'Pozos' : 'Administración')
|
|
||||||
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
|
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
|
||||||
const alertsButtonLabel = unacknowledgedCount > 0
|
const alertsButtonLabel = unacknowledgedCount > 0
|
||||||
? `Ver ${unacknowledgedCount} alertas pendientes`
|
? `Ver ${unacknowledgedCount} alertas pendientes`
|
||||||
@@ -159,8 +164,8 @@ export default function AdminLayout() {
|
|||||||
const getProjectAlarmCount = (projectId: string) => {
|
const getProjectAlarmCount = (projectId: string) => {
|
||||||
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
|
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
|
||||||
}
|
}
|
||||||
const getWellAlarmCount = (wellId: string) => {
|
const getAssetAlarmCount = (assetId: string) => {
|
||||||
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === wellId).length
|
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === assetId).length
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -306,35 +311,90 @@ export default function AdminLayout() {
|
|||||||
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
|
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
|
||||||
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
||||||
{sidebarProjects.map((project) => {
|
{sidebarProjects.map((project) => {
|
||||||
const projectAlarmCount = getProjectAlarmCount(project.id)
|
const projectWells = sidebarProjectWells.find((entry) => entry.project.id === project.id)?.wells ?? []
|
||||||
|
const isProjectExpanded = !collapsedSidebarProjectIds.includes(project.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.id}>
|
<div key={project.id}>
|
||||||
<NavLink
|
<div className="flex items-center gap-1">
|
||||||
to={`/app/admin/projects/${project.id}`}
|
{(() => {
|
||||||
title={project.name}
|
const projectAlarmCount = getProjectAlarmCount(project.id)
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
return (
|
||||||
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
<NavLink
|
||||||
isActive
|
to={`/app/admin/projects/${project.id}`}
|
||||||
? 'bg-[#24282e] text-white'
|
title={project.name}
|
||||||
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
className={({ isActive }) =>
|
||||||
)
|
cn(
|
||||||
}
|
'flex min-w-0 flex-1 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
||||||
>
|
isActive
|
||||||
<span
|
? 'bg-[#24282e] text-white'
|
||||||
className={cn(
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
||||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
)
|
||||||
project.is_active ? 'bg-success' : 'bg-muted-foreground'
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
|
project.is_active ? 'bg-success' : 'bg-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{project.name}</span>
|
||||||
|
{projectAlarmCount > 0 && (
|
||||||
|
<Badge className="ml-auto shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
||||||
|
{projectAlarmCount}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
/>
|
</NavLink>
|
||||||
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
)
|
||||||
{projectAlarmCount > 0 && (
|
})()}
|
||||||
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
|
||||||
{projectAlarmCount}
|
{projectWells.length > 0 && (
|
||||||
</Badge>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsedSidebarProjectIds((ids) =>
|
||||||
|
ids.includes(project.id)
|
||||||
|
? ids.filter((id) => id !== project.id)
|
||||||
|
: [...ids, project.id]
|
||||||
|
)}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-[#8b98aa] transition-colors hover:bg-[#171b20] hover:text-white"
|
||||||
|
aria-label={isProjectExpanded ? `Ocultar pozos de ${project.name}` : `Mostrar pozos de ${project.name}`}
|
||||||
|
aria-expanded={isProjectExpanded}
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isProjectExpanded && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</div>
|
||||||
|
|
||||||
|
{projectWells.length > 0 && isProjectExpanded && (
|
||||||
|
<div className="ml-3 space-y-0.5 border-l border-[#232831] pl-2.5">
|
||||||
|
{projectWells.map((well) => {
|
||||||
|
const wellAlarmCount = getAssetAlarmCount(well.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={well.id}
|
||||||
|
to={`/app/admin/projects/${project.id}`}
|
||||||
|
title={`${well.name} · ${project.name}`}
|
||||||
|
className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[12px] text-[#9aa8ba] transition-colors hover:bg-[#24282e] hover:text-white"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
|
well.is_active ? 'bg-[#00b7ff]' : 'bg-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{well.name}</span>
|
||||||
|
{wellAlarmCount > 0 && (
|
||||||
|
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
||||||
|
{wellAlarmCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -375,64 +435,6 @@ export default function AdminLayout() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{section.label === 'General' && sidebarWells.length > 0 && !isDesktopSidebarCollapsed && (
|
|
||||||
<div className="mt-2 space-y-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsWellsNavOpen((open) => !open)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
|
||||||
isWellRouteActive
|
|
||||||
? 'bg-[#24282e] text-white font-semibold [&>svg]:text-primary'
|
|
||||||
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
|
||||||
)}
|
|
||||||
aria-expanded={isWellsNavOpen}
|
|
||||||
aria-controls="admin-sidebar-wells"
|
|
||||||
>
|
|
||||||
<Gauge className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="min-w-0 flex-1 text-left">Pozos</span>
|
|
||||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isWellsNavOpen && '-rotate-90')} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isWellsNavOpen && (
|
|
||||||
<div id="admin-sidebar-wells" className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
|
||||||
{sidebarWells.map((well) => {
|
|
||||||
const wellAlarmCount = getWellAlarmCount(well.id)
|
|
||||||
const wellLabel = well.name || well.code || well.id.slice(0, 8)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={well.id}
|
|
||||||
to={`/app/admin/wells/${well.id}`}
|
|
||||||
title={wellLabel}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-[#24282e] text-white'
|
|
||||||
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
|
||||||
well.is_active ? 'bg-success' : 'bg-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{wellLabel}</span>
|
|
||||||
{wellAlarmCount > 0 && (
|
|
||||||
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
|
||||||
{wellAlarmCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import SetupPage from '@/features/auth/pages/SetupPage'
|
|||||||
import ActivatePage from '@/features/auth/pages/ActivatePage'
|
import ActivatePage from '@/features/auth/pages/ActivatePage'
|
||||||
import AdminLayout from '@/app/layouts/AdminLayout'
|
import AdminLayout from '@/app/layouts/AdminLayout'
|
||||||
import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage'
|
import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage'
|
||||||
import RealtimePage from '@/features/admin/dashboard/pages/RealtimePage'
|
|
||||||
import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage'
|
import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage'
|
||||||
import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage'
|
import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage'
|
||||||
import ProjectDetailsPage from '@/features/admin/projects/pages/ProjectDetailsPage'
|
import ProjectDetailsPage from '@/features/admin/projects/pages/ProjectDetailsPage'
|
||||||
@@ -23,7 +22,7 @@ const WellDashboardPage = lazy(() => import('@/features/admin/dashboard/pages/We
|
|||||||
export const adminChildRoutes = [
|
export const adminChildRoutes = [
|
||||||
{ index: true, element: <Navigate to="dashboard" replace /> },
|
{ index: true, element: <Navigate to="dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
{ path: 'dashboard', element: <DashboardPage /> },
|
||||||
{ path: 'realtime', element: <RealtimePage /> },
|
{ path: 'realtime', element: <DashboardPage /> },
|
||||||
{ path: 'wells/:wellId', element: <Suspense fallback={null}><WellDashboardPage /></Suspense> },
|
{ path: 'wells/:wellId', element: <Suspense fallback={null}><WellDashboardPage /></Suspense> },
|
||||||
{ path: 'projects', element: <ProjectListPage /> },
|
{ path: 'projects', element: <ProjectListPage /> },
|
||||||
{ path: 'projects/new', element: <CreateProjectPage /> },
|
{ path: 'projects/new', element: <CreateProjectPage /> },
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ export interface AlarmEvent {
|
|||||||
current_value?: number | null
|
current_value?: number | null
|
||||||
alarm_min?: number | null
|
alarm_min?: number | null
|
||||||
alarm_max?: number | null
|
alarm_max?: number | null
|
||||||
alarm_type:
|
alarm_type: 'ABOVE_MAX' | 'BELOW_MIN' | 'TELEMETRY_SILENCE' | 'AGENT_OFFLINE' | string
|
||||||
| 'ABOVE_MAX'
|
|
||||||
| 'BELOW_MIN'
|
|
||||||
| 'TELEMETRY_SILENCE'
|
|
||||||
| 'TELEMETRY_SILENCE_HF'
|
|
||||||
| 'TELEMETRY_SILENCE_LF'
|
|
||||||
| 'AGENT_OFFLINE'
|
|
||||||
| string
|
|
||||||
message?: string | null
|
message?: string | null
|
||||||
acknowledged?: boolean | null
|
acknowledged?: boolean | null
|
||||||
acknowledged_at?: string | null
|
acknowledged_at?: string | null
|
||||||
|
|||||||
@@ -82,14 +82,6 @@ const correctionReport = {
|
|||||||
object_key: 'anh/reports/ANH_2026-06-15_CORR1.json',
|
object_key: 'anh/reports/ANH_2026-06-15_CORR1.json',
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableReport = {
|
|
||||||
...correctionReport,
|
|
||||||
id: 'report-available-anh',
|
|
||||||
filename: 'ANH_2026-06-15_AVAILABLE.json',
|
|
||||||
status: 'AVAILABLE_FOR_ANH' as const,
|
|
||||||
available_for_anh_at: '2026-06-18T13:05:00Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSchedule = {
|
const defaultSchedule = {
|
||||||
id: 'schedule-1',
|
id: 'schedule-1',
|
||||||
operator_name: 'Kyrbot',
|
operator_name: 'Kyrbot',
|
||||||
@@ -255,32 +247,6 @@ describe('ANHReportPage audit hash and download behavior', () => {
|
|||||||
expect(container.textContent).not.toContain('auto-envío')
|
expect(container.textContent).not.toContain('auto-envío')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the ANH console header and report status summary cards', async () => {
|
|
||||||
vi.mocked(listANHReports).mockResolvedValue([generatedReport, { ...report, status: 'APPROVED' }, availableReport])
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ANHReportPage />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitForText(container, 'ANH_2026-06-15_AVAILABLE.json')
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('CONSOLA ADMIN')
|
|
||||||
expect(container.textContent).toContain('Telemetría de Pozos')
|
|
||||||
expect(container.textContent).toContain('CONSOLA ANH')
|
|
||||||
expect(container.textContent).toContain('Resolución 0651')
|
|
||||||
expect(container.textContent).toContain('Administración, revisión y trazabilidad de reportes JSON bajo la Resolución 0651')
|
|
||||||
|
|
||||||
const statCards = Array.from(container.querySelectorAll('article')).map((element) => element.textContent ?? '')
|
|
||||||
expect(statCards.some((text) => text.includes('3') && text.includes('Total'))).toBe(true)
|
|
||||||
expect(statCards.some((text) => text.includes('1') && text.includes('Borrador'))).toBe(true)
|
|
||||||
expect(statCards.some((text) => text.includes('1') && text.includes('Aprob.'))).toBe(true)
|
|
||||||
expect(statCards.some((text) => text.includes('1') && text.includes('Disponible'))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saves schedule time and active state without exposing or sending timezone', async () => {
|
it('saves schedule time and active state without exposing or sending timezone', async () => {
|
||||||
vi.mocked(updateANHReportSchedule).mockResolvedValue({
|
vi.mocked(updateANHReportSchedule).mockResolvedValue({
|
||||||
...defaultSchedule,
|
...defaultSchedule,
|
||||||
|
|||||||
@@ -406,19 +406,9 @@ export default function ANHReportPage() {
|
|||||||
total: reports?.length ?? 0,
|
total: reports?.length ?? 0,
|
||||||
generated: reports?.filter((report) => report.status === 'GENERATED').length ?? 0,
|
generated: reports?.filter((report) => report.status === 'GENERATED').length ?? 0,
|
||||||
approved: reports?.filter((report) => report.status === 'APPROVED').length ?? 0,
|
approved: reports?.filter((report) => report.status === 'APPROVED').length ?? 0,
|
||||||
sent: reports?.filter((report) => ['SENT', 'DISPOSED', 'AVAILABLE_FOR_ANH'].includes(report.status)).length ?? 0,
|
sent: reports?.filter((report) => report.status === 'SENT' || report.status === 'DISPOSED').length ?? 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ label: 'Total', value: reportStats.total, accent: 'text-white', bg: 'bg-[#0f1318]' },
|
|
||||||
{ label: 'Borrador', value: reportStats.generated, accent: 'text-[#ffb020]', bg: 'bg-[#ffb020]/10' },
|
|
||||||
{ label: 'Aprob.', value: reportStats.approved, accent: 'text-[#27d796]', bg: 'bg-[#27d796]/10' },
|
|
||||||
{ label: 'Disponible', value: reportStats.sent, accent: 'text-[#00b7ff]', bg: 'bg-[#00b7ff]/10' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const panelClass = 'rounded-2xl border border-[#2a3038] bg-[#15191e] shadow-xl shadow-black/15'
|
|
||||||
const insetPanelClass = 'rounded-xl border border-[#2a3038] bg-[#0f1318]'
|
|
||||||
|
|
||||||
const getStatusBadge = (report: ANHReport) => {
|
const getStatusBadge = (report: ANHReport) => {
|
||||||
switch (report.status) {
|
switch (report.status) {
|
||||||
case 'GENERATED':
|
case 'GENERATED':
|
||||||
@@ -442,137 +432,140 @@ export default function ANHReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 bg-[#090b0f] pb-8 text-foreground">
|
<div className="space-y-6">
|
||||||
<div className="space-y-5">
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#2a3038] pb-3">
|
<Card className="relative overflow-hidden border-border bg-card/80 shadow-2xl shadow-black/20">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.22em] text-[#9aa8ba]">
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.16),transparent_34%),linear-gradient(135deg,rgba(249,115,22,0.10),transparent_32%)]" />
|
||||||
<span>CONSOLA ADMIN</span>
|
<CardContent className="relative p-5 lg:p-6">
|
||||||
<span className="text-[#4e5a68]">/</span>
|
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<span className="text-[#ff7a1a]">Telemetría de Pozos</span>
|
|
||||||
</div>
|
|
||||||
<Badge className="border-[#2a3038] bg-[#15191e] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">
|
|
||||||
Resolución 0651
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<section className={cn(panelClass, 'relative overflow-hidden p-4 lg:p-5')} aria-label="Consola ANH">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,122,26,0.14),transparent_30%)]" />
|
|
||||||
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
|
|
||||||
<div className="max-w-3xl space-y-3">
|
<div className="max-w-3xl space-y-3">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-[#ff7a1a]/25 bg-[#ff7a1a]/10 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.22em] text-[#ff7a1a]">
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.22em] text-primary">
|
||||||
<FileJson className="h-3.5 w-3.5" /> CONSOLA ANH
|
<FileJson className="h-3.5 w-3.5" /> Consola ANH
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">Reportes ANH</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground md:text-3xl">Reportes ANH</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-[#9aa8ba]">
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||||
Administración, revisión y trazabilidad de reportes JSON bajo la Resolución 0651. La consola conserva evidencia de auditoría, permite aprobar borradores y dispone artefactos sellados para consulta ANH.
|
Gestión, revisión y auditoría de archivos JSON según la Resolución 0651. La generación programada deja reportes listos para revisión, sin aprobarlos automáticamente.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:min-w-[440px]">
|
<div className="flex flex-col gap-3 sm:flex-row xl:items-center">
|
||||||
{statCards.map((stat) => (
|
<div className="grid grid-cols-4 gap-2 rounded-2xl border border-border/70 bg-background/60 p-2 backdrop-blur">
|
||||||
<article key={stat.label} className={cn('rounded-xl border border-[#2a3038] p-3 text-right shadow-lg shadow-black/10', stat.bg)}>
|
<div className="min-w-16 rounded-xl bg-secondary/60 px-3 py-2 text-center">
|
||||||
<span className={cn('block text-2xl font-semibold tabular-nums', stat.accent)}>{stat.value}</span>
|
<span className="block text-lg font-bold text-foreground">{reportStats.total}</span>
|
||||||
<span className="mt-1 block text-[10px] font-bold uppercase tracking-[0.16em] text-[#9aa8ba]">{stat.label}</span>
|
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Total</span>
|
||||||
</article>
|
</div>
|
||||||
))}
|
<div className="min-w-16 rounded-xl bg-amber-500/10 px-3 py-2 text-center">
|
||||||
|
<span className="block text-lg font-bold text-amber-500">{reportStats.generated}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Borrador</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-16 rounded-xl bg-emerald-500/10 px-3 py-2 text-center">
|
||||||
|
<span className="block text-lg font-bold text-emerald-500">{reportStats.approved}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Aprob.</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-16 rounded-xl bg-blue-500/10 px-3 py-2 text-center">
|
||||||
|
<span className="block text-lg font-bold text-blue-500">{reportStats.sent}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Disponible</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<section className={cn(panelClass, 'p-4 lg:p-5')} aria-label="Regeneración programada">
|
<Card className="border-border bg-card/80 shadow-xl shadow-black/10">
|
||||||
<div className="grid gap-5 xl:grid-cols-[1.05fr_1fr] xl:items-start">
|
<CardContent className="p-4 lg:p-5">
|
||||||
<div className="space-y-4">
|
<div className="grid gap-5 xl:grid-cols-[1.2fr_1fr] xl:items-start">
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-white">
|
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-[#ff7a1a]/25 bg-[#ff7a1a]/10 text-[#ff7a1a]">
|
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
Regeneración programada
|
Regeneración programada
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-[#9aa8ba]">
|
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||||
Generación oficial diaria: 06:00 AM Colombia / 11:00Z. La regeneración intradía configurable es independiente y solo actualiza el reporte vigente si necesita regenerarse durante el día.
|
Generación oficial diaria: 06:00 AM Colombia / 11:00Z. La regeneración intradía configurable es independiente y solo actualiza el reporte vigente si necesitás regenerarlo durante el día.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={cn(
|
<Badge className={cn(
|
||||||
'rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-wider',
|
'rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-wider',
|
||||||
isScheduleActive ? 'border-[#27d796]/25 bg-[#27d796]/10 text-[#27d796] shadow-[0_0_18px_rgba(39,215,150,0.12)]' : 'border-[#2a3038] bg-[#0f1318] text-[#9aa8ba]'
|
isScheduleActive ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20 shadow-[0_0_18px_rgba(16,185,129,0.12)]' : 'bg-secondary text-muted-foreground border-border'
|
||||||
)}>
|
)}>
|
||||||
{isScheduleActive ? 'Regeneración activa' : 'Regeneración inactiva'}
|
{isScheduleActive ? 'Regeneración activa' : 'Regeneración inactiva'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-[#00b7ff]/20 bg-[#00b7ff]/10 p-4 text-xs leading-relaxed text-[#8adfff]">
|
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/[0.08] p-3.5 text-xs leading-relaxed text-amber-400/95">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-2">
|
||||||
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-[#00b7ff]" />
|
<Clock className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-white">Generación oficial diaria:</span> el reporte diario se crea todos los días a las <span className="font-semibold">06:00 AM Colombia / 11:00Z</span>. La regeneración intradía configurable es independiente; si encuentra un reporte en borrador, actualiza ese mismo reporte y su hora de última generación.
|
<span className="font-bold">Generación oficial diaria:</span> el reporte diario se crea todos los días a las <span className="font-semibold">06:00 AM Colombia / 11:00Z</span>. La regeneración intradía configurable es independiente; si encuentra un reporte en borrador, actualiza ese mismo reporte y su hora de última generación.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(insetPanelClass, 'space-y-4 p-4')}>
|
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 space-y-4">
|
||||||
<div className="grid grid-cols-1 items-end gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#9aa8ba]">Hora de regeneración</span>
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Hora de regeneración</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
ref={scheduleTimeRef}
|
ref={scheduleTimeRef}
|
||||||
defaultValue={scheduleTime}
|
defaultValue={scheduleTime}
|
||||||
onChange={(event) => setScheduleTime(event.target.value)}
|
onChange={(event) => setScheduleTime(event.target.value)}
|
||||||
onInput={(event) => setScheduleTime(event.currentTarget.value)}
|
onInput={(event) => setScheduleTime(event.currentTarget.value)}
|
||||||
className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] px-3 py-2 text-sm text-white outline-none [color-scheme:dark] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
|
className="w-full bg-secondary/80 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex h-10 items-center gap-2 rounded-lg border border-[#2a3038] bg-[#15191e] px-3 text-sm text-[#9aa8ba]">
|
<label className="flex h-10 items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 text-sm text-muted-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
ref={scheduleActiveRef}
|
ref={scheduleActiveRef}
|
||||||
checked={isScheduleActive}
|
checked={isScheduleActive}
|
||||||
onChange={(event) => setIsScheduleActive(event.target.checked)}
|
onChange={(event) => setIsScheduleActive(event.target.checked)}
|
||||||
className="h-4 w-4 accent-[#ff7a1a]"
|
className="h-4 w-4 accent-primary"
|
||||||
/>
|
/>
|
||||||
Activa
|
Activa
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-[#00b7ff]/20 bg-[#00b7ff]/10 p-3 text-xs leading-relaxed text-[#8adfff]">
|
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.07] p-3 text-xs leading-relaxed text-blue-300/95">
|
||||||
<span className="font-semibold text-white">Hora Colombia (America/Bogota):</span>{' '}
|
<span className="font-semibold text-foreground">Hora Colombia (America/Bogota):</span>{' '}
|
||||||
la zona horaria es administrada por el sistema y no es editable desde esta consola.
|
la zona horaria es administrada por el sistema y no es editable desde esta consola.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-[#2a3038] bg-[#15191e] p-3">
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-3">
|
||||||
<h3 className="mb-3 text-[10px] font-semibold uppercase tracking-wider text-[#9aa8ba]">Diagnóstico operativo</h3>
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Diagnóstico operativo</h3>
|
||||||
<div className="grid gap-2 text-[10px] text-[#9aa8ba] sm:grid-cols-3">
|
<div className="grid gap-2 text-[10px] text-muted-foreground sm:grid-cols-3">
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-3">
|
<div className="rounded-md border border-border/60 bg-background p-2">
|
||||||
<span className="block font-semibold text-white">Última ejecución</span>
|
<span className="block font-semibold text-foreground">Última ejecución</span>
|
||||||
{primarySchedule?.last_run_at ? new Date(primarySchedule.last_run_at).toLocaleString() : 'Sin ejecuciones'}
|
{primarySchedule?.last_run_at ? new Date(primarySchedule.last_run_at).toLocaleString() : 'Sin ejecuciones'}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-3">
|
<div className="rounded-md border border-border/60 bg-background p-2">
|
||||||
<span className="block font-semibold text-white">Último estado</span>
|
<span className="block font-semibold text-foreground">Último estado</span>
|
||||||
{getScheduleStatusLabel(primarySchedule?.last_status)}
|
{getScheduleStatusLabel(primarySchedule?.last_status)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-3">
|
<div className="rounded-md border border-border/60 bg-background p-2">
|
||||||
<span className="block font-semibold text-white">Último error</span>
|
<span className="block font-semibold text-foreground">Último error</span>
|
||||||
{primarySchedule?.last_error || 'Sin errores registrados'}
|
{primarySchedule?.last_error || 'Sin errores registrados'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 border-t border-[#2a3038] pt-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-xs text-[#9aa8ba]">
|
<p className="text-xs text-muted-foreground">
|
||||||
<span className="font-semibold text-white">Próxima regeneración:</span>{' '}
|
<span className="font-semibold text-foreground">Próxima regeneración:</span>{' '}
|
||||||
{primarySchedule?.next_run_at ? new Date(primarySchedule.next_run_at).toLocaleString() : 'Pendiente de cálculo'}
|
{primarySchedule?.next_run_at ? new Date(primarySchedule.next_run_at).toLocaleString() : 'Pendiente de cálculo'}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSaveSchedule}
|
onClick={handleSaveSchedule}
|
||||||
disabled={!primarySchedule || isSavingSchedule}
|
disabled={!primarySchedule || isSavingSchedule}
|
||||||
className="flex items-center gap-2 bg-[#ff7a1a] text-[#090b0f] hover:bg-[#ff8d3d]"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isSavingSchedule ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
{isSavingSchedule ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
Guardar
|
Guardar
|
||||||
@@ -580,16 +573,16 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Historial */}
|
{/* Historial */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className={cn(panelClass, 'overflow-hidden')}>
|
<Card className="border-border">
|
||||||
<div className="flex flex-col gap-3 border-b border-[#2a3038] px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-white">
|
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 text-[#ff7a1a]" /> Historial de disponibilidad y aprobación
|
<Clock className="h-4 w-4 text-muted-foreground" /> Historial de disponibilidad y aprobación
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -600,7 +593,7 @@ export default function ANHReportPage() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
||||||
className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] py-1.5 pl-8 pr-3 text-xs text-white transition-all outline-none placeholder:text-[#687485] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40 sm:w-64"
|
className="bg-secondary border border-border rounded-md py-1.5 pl-8 pr-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary w-48 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -609,14 +602,14 @@ export default function ANHReportPage() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#2a3038] bg-[#0f1318]">
|
<tr className="border-b border-border bg-secondary/20">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-[#9aa8ba]">Archivo</th>
|
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Archivo</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-[#9aa8ba]">Estado</th>
|
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Estado</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-[#9aa8ba]">Fecha Operacional</th>
|
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Fecha Operacional</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-[#9aa8ba]">Acciones</th>
|
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#2a3038]">
|
<tbody className="divide-y divide-border">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr><td colSpan={4} className="text-center py-10 text-sm text-muted-foreground">Cargando historial de reportes...</td></tr>
|
<tr><td colSpan={4} className="text-center py-10 text-sm text-muted-foreground">Cargando historial de reportes...</td></tr>
|
||||||
) : filteredReports?.length === 0 ? (
|
) : filteredReports?.length === 0 ? (
|
||||||
@@ -625,8 +618,8 @@ export default function ANHReportPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={report.id}
|
key={report.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer transition-colors hover:bg-[#0f1318]',
|
'hover:bg-secondary/30 cursor-pointer transition-colors',
|
||||||
selectedReportId === report.id && 'bg-[#0f1318]'
|
selectedReportId === report.id && 'bg-secondary/50'
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedReportId(report.id)
|
setSelectedReportId(report.id)
|
||||||
@@ -637,8 +630,8 @@ export default function ANHReportPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="h-4 w-4 text-muted-foreground shrink-0" />
|
<FileCode className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white">{report.filename}</p>
|
<p className="text-sm font-medium text-foreground">{report.filename}</p>
|
||||||
<p className="text-xs text-[#9aa8ba]">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(report.size_bytes / 1024).toFixed(1)} KB
|
{(report.size_bytes / 1024).toFixed(1)} KB
|
||||||
{typeof report.correction_version === 'number' && report.correction_version > 0 ? ` · Corrección v${report.correction_version}` : ''}
|
{typeof report.correction_version === 'number' && report.correction_version > 0 ? ` · Corrección v${report.correction_version}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -648,7 +641,7 @@ export default function ANHReportPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{getStatusBadge(report)}
|
{getStatusBadge(report)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#9aa8ba]">
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p>{formatOperationalDate(report.period_start)}</p>
|
<p>{formatOperationalDate(report.period_start)}</p>
|
||||||
<p className="text-xs">Última generación {formatCreationTime(report.generated_at)}</p>
|
<p className="text-xs">Última generación {formatCreationTime(report.generated_at)}</p>
|
||||||
@@ -658,7 +651,7 @@ export default function ANHReportPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-[#9aa8ba] hover:bg-[#ff7a1a]/10 hover:text-[#ff7a1a]"
|
className="h-8 w-8 hover:text-primary"
|
||||||
aria-label={`Descargar ${report.filename}`}
|
aria-label={`Descargar ${report.filename}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -679,18 +672,18 @@ export default function ANHReportPage() {
|
|||||||
|
|
||||||
{/* Panel lateral de Vista Previa / Edición */}
|
{/* Panel lateral de Vista Previa / Edición */}
|
||||||
<div>
|
<div>
|
||||||
<Card className={cn(panelClass, 'sticky top-4 overflow-hidden')}>
|
<Card className="border-border sticky top-4">
|
||||||
{!selectedReportId ? (
|
{!selectedReportId ? (
|
||||||
<CardContent className="flex h-96 flex-col items-center justify-center p-6 text-center">
|
<CardContent className="flex flex-col items-center justify-center h-96 text-center p-6">
|
||||||
<FileJson className="h-12 w-12 text-muted-foreground/20 mb-3" />
|
<FileJson className="h-12 w-12 text-muted-foreground/20 mb-3" />
|
||||||
<h3 className="mb-1 text-base font-semibold text-white">Vista Previa & Revisión</h3>
|
<h3 className="text-base font-semibold text-foreground mb-1">Vista Previa & Revisión</h3>
|
||||||
<p className="max-w-[260px] text-sm leading-relaxed text-[#9aa8ba]">Selecciona un reporte de la lista para ver su estructura y aprobarlo.</p>
|
<p className="text-sm leading-relaxed text-muted-foreground max-w-[260px]">Selecciona un reporte de la lista para ver su estructura y aprobarlo.</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold text-white">Detalle del Reporte</h3>
|
<h3 className="text-base font-semibold">Detalle del Reporte</h3>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[#9aa8ba] hover:bg-[#0f1318] hover:text-white" onClick={() => { setSelectedReportId(null); setIsEditing(false); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setSelectedReportId(null); setIsEditing(false); }}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -698,7 +691,7 @@ export default function ANHReportPage() {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
/* Modo Edición */
|
/* Modo Edición */
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className={cn(insetPanelClass, 'p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
<Edit2 className="h-3.5 w-3.5 text-primary" /> Editar JSON de Reporte
|
<Edit2 className="h-3.5 w-3.5 text-primary" /> Editar JSON de Reporte
|
||||||
@@ -711,21 +704,21 @@ export default function ANHReportPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
className="h-96 w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] p-4 font-mono text-sm leading-relaxed text-emerald-400 outline-none custom-scrollbar focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
|
className="w-full h-96 bg-background text-emerald-400 font-mono text-sm leading-relaxed p-4 rounded-md border border-border focus:outline-none focus:ring-1 focus:ring-primary resize-none custom-scrollbar"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 border-[#2a3038] bg-[#0f1318] text-xs text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
|
className="flex-1 text-xs"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
disabled={isSavingEdit}
|
disabled={isSavingEdit}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 bg-[#ff7a1a] text-xs text-[#090b0f] hover:bg-[#ff8d3d]"
|
className="flex-1 text-xs"
|
||||||
onClick={handleSaveEdit}
|
onClick={handleSaveEdit}
|
||||||
disabled={isSavingEdit}
|
disabled={isSavingEdit}
|
||||||
>
|
>
|
||||||
@@ -746,7 +739,7 @@ export default function ANHReportPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tarjeta Resumen y Diagnóstico de Calidad */}
|
{/* Tarjeta Resumen y Diagnóstico de Calidad */}
|
||||||
{metrics && quality && (
|
{metrics && quality && (
|
||||||
<div className={cn(insetPanelClass, 'space-y-3 p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Diagnóstico de Calidad y Cobertura</h4>
|
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Diagnóstico de Calidad y Cobertura</h4>
|
||||||
<Badge className={cn(
|
<Badge className={cn(
|
||||||
@@ -761,7 +754,7 @@ export default function ANHReportPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full border border-[#2a3038] bg-[#090b0f]">
|
<div className="w-full bg-secondary h-1.5 rounded-full overflow-hidden border border-border/40">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full rounded-full transition-all duration-500",
|
"h-full rounded-full transition-all duration-500",
|
||||||
@@ -776,36 +769,36 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-[9px]">
|
<div className="grid grid-cols-3 gap-2 text-[9px]">
|
||||||
<div className="rounded border border-[#2a3038] bg-[#090b0f] p-2 text-center">
|
<div className="p-2 rounded bg-background border border-border/40 text-center">
|
||||||
<span className="block font-bold text-white">{quality.nullFields}</span>
|
<span className="block font-bold text-foreground">{quality.nullFields}</span>
|
||||||
<span className="text-muted-foreground text-[8px]">Nulos</span>
|
<span className="text-muted-foreground text-[8px]">Nulos</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[#2a3038] bg-[#090b0f] p-2 text-center">
|
<div className="p-2 rounded bg-background border border-border/40 text-center">
|
||||||
<span className="block font-bold text-white">{quality.emptyStrings}</span>
|
<span className="block font-bold text-foreground">{quality.emptyStrings}</span>
|
||||||
<span className="text-muted-foreground text-[8px]">Vacíos</span>
|
<span className="text-muted-foreground text-[8px]">Vacíos</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-[#2a3038] bg-[#090b0f] p-2 text-center">
|
<div className="p-2 rounded bg-background border border-border/40 text-center">
|
||||||
<span className={cn("block font-bold", quality.excepciones > 0 ? "text-amber-500" : "text-white")}>
|
<span className={cn("block font-bold", quality.excepciones > 0 ? "text-amber-500" : "text-foreground")}>
|
||||||
{quality.excepciones}
|
{quality.excepciones}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[8px]">Excepciones</span>
|
<span className="text-muted-foreground text-[8px]">Excepciones</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 border-t border-[#2a3038] pt-1 text-[9px]">
|
<div className="grid grid-cols-2 gap-2 text-[9px] pt-1 border-t border-border/20">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Registros Frecuencia:</span>
|
<span className="text-muted-foreground">Registros Frecuencia:</span>
|
||||||
<span className="float-right font-bold text-white">{metrics.altaTanques + metrics.altaMedidores + metrics.altaPruebas}</span>
|
<span className="float-right font-bold text-foreground">{metrics.altaTanques + metrics.altaMedidores + metrics.altaPruebas}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Registros Transac:</span>
|
<span className="text-muted-foreground">Registros Transac:</span>
|
||||||
<span className="float-right font-bold text-white">{metrics.bajaMovimientos + metrics.bajaParadas}</span>
|
<span className="float-right font-bold text-foreground">{metrics.bajaMovimientos + metrics.bajaParadas}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn(insetPanelClass, 'p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Hash className="h-3.5 w-3.5 text-primary" />
|
<Hash className="h-3.5 w-3.5 text-primary" />
|
||||||
<span className="text-xs font-medium text-muted-foreground">Hash interno de auditoría (SHA-384)</span>
|
<span className="text-xs font-medium text-muted-foreground">Hash interno de auditoría (SHA-384)</span>
|
||||||
@@ -814,7 +807,7 @@ export default function ANHReportPage() {
|
|||||||
Evidencia interna calculada sobre los bytes sellados; no es un campo normativo del JSON ANH.
|
Evidencia interna calculada sobre los bytes sellados; no es un campo normativo del JSON ANH.
|
||||||
</p>
|
</p>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 pr-8 font-mono text-[10px] leading-normal break-all text-[#9aa8ba]">
|
<div className="bg-background rounded-md p-2 text-[10px] font-mono break-all text-muted-foreground border border-border pr-8 leading-normal">
|
||||||
{selectedReport?.hash_sha384}
|
{selectedReport?.hash_sha384}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -828,7 +821,7 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(insetPanelClass, 'space-y-3 p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 text-primary" /> Corrección
|
<Clock className="h-3.5 w-3.5 text-primary" /> Corrección
|
||||||
@@ -841,42 +834,42 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 text-[10px] text-muted-foreground sm:grid-cols-2">
|
<div className="grid gap-2 text-[10px] text-muted-foreground sm:grid-cols-2">
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2">
|
<div className="rounded-md border border-border/60 bg-background p-2">
|
||||||
<span className="block font-semibold text-white">Estado</span>
|
<span className="block font-semibold text-foreground">Estado</span>
|
||||||
{selectedReport ? getStatusLabel(selectedReport.status) : 'N/A'}
|
{selectedReport ? getStatusLabel(selectedReport.status) : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2">
|
<div className="rounded-md border border-border/60 bg-background p-2">
|
||||||
<span className="block font-semibold text-white">Fecha límite de corrección</span>
|
<span className="block font-semibold text-foreground">Fecha límite de corrección</span>
|
||||||
{formatDateTime(selectedReport?.correction_deadline_at)}
|
{formatDateTime(selectedReport?.correction_deadline_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedReport?.rejection_reason && (
|
{selectedReport?.rejection_reason && (
|
||||||
<p className="text-[10px] leading-relaxed text-muted-foreground">
|
<p className="text-[10px] leading-relaxed text-muted-foreground">
|
||||||
<span className="font-semibold text-white">Motivo de inconsistencia:</span> {selectedReport.rejection_reason}
|
<span className="font-semibold text-foreground">Motivo de inconsistencia:</span> {selectedReport.rejection_reason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{selectedReport?.correction_reason && (
|
{selectedReport?.correction_reason && (
|
||||||
<p className="text-[10px] leading-relaxed text-muted-foreground">
|
<p className="text-[10px] leading-relaxed text-muted-foreground">
|
||||||
<span className="font-semibold text-white">Motivo de corrección:</span> {selectedReport.correction_reason}
|
<span className="font-semibold text-foreground">Motivo de corrección:</span> {selectedReport.correction_reason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canRegisterInconsistency(selectedReport) && (
|
{canRegisterInconsistency(selectedReport) && (
|
||||||
<div className="space-y-2 border-t border-[#2a3038] pt-3">
|
<div className="space-y-2 border-t border-border/50 pt-3">
|
||||||
<label className="space-y-1 block">
|
<label className="space-y-1 block">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de rechazo o inconsistencia</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de rechazo o inconsistencia</span>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Motivo de rechazo o inconsistencia"
|
aria-label="Motivo de rechazo o inconsistencia"
|
||||||
value={rejectionReason}
|
value={rejectionReason}
|
||||||
onChange={(event) => setRejectionReason(event.target.value)}
|
onChange={(event) => setRejectionReason(event.target.value)}
|
||||||
className="h-16 w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
|
className="h-16 w-full resize-none rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-[#2a3038] bg-[#0f1318] text-xs text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
|
className="w-full text-xs"
|
||||||
onClick={handleRegisterRejection}
|
onClick={handleRegisterRejection}
|
||||||
disabled={isRegisteringRejection || !rejectionReason.trim()}
|
disabled={isRegisteringRejection || !rejectionReason.trim()}
|
||||||
>
|
>
|
||||||
@@ -887,20 +880,20 @@ export default function ANHReportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canCreateCorrection(selectedReport) && (
|
{canCreateCorrection(selectedReport) && (
|
||||||
<div className="space-y-2 border-t border-[#2a3038] pt-3">
|
<div className="space-y-2 border-t border-border/50 pt-3">
|
||||||
<label className="space-y-1 block">
|
<label className="space-y-1 block">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de corrección</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de corrección</span>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Motivo de corrección"
|
aria-label="Motivo de corrección"
|
||||||
value={correctionReason}
|
value={correctionReason}
|
||||||
onChange={(event) => setCorrectionReason(event.target.value)}
|
onChange={(event) => setCorrectionReason(event.target.value)}
|
||||||
className="h-16 w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
|
className="h-16 w-full resize-none rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-[#2a3038] bg-[#0f1318] text-xs text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
|
className="w-full text-xs"
|
||||||
onClick={handleCreateCorrection}
|
onClick={handleCreateCorrection}
|
||||||
disabled={isCreatingCorrection || !correctionReason.trim()}
|
disabled={isCreatingCorrection || !correctionReason.trim()}
|
||||||
>
|
>
|
||||||
@@ -911,20 +904,20 @@ export default function ANHReportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canMarkAvailableForANH(selectedReport) && (
|
{canMarkAvailableForANH(selectedReport) && (
|
||||||
<div className="space-y-2 border-t border-[#2a3038] pt-3">
|
<div className="space-y-2 border-t border-border/50 pt-3">
|
||||||
<label className="space-y-1 block">
|
<label className="space-y-1 block">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Nota de disponibilidad</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Nota de disponibilidad</span>
|
||||||
<input
|
<input
|
||||||
aria-label="Nota de disponibilidad para ANH"
|
aria-label="Nota de disponibilidad para ANH"
|
||||||
value={availabilityNote}
|
value={availabilityNote}
|
||||||
onChange={(event) => setAvailabilityNote(event.target.value)}
|
onChange={(event) => setAvailabilityNote(event.target.value)}
|
||||||
className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
|
className="w-full rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
placeholder="Disponible para consulta ANH"
|
placeholder="Disponible para consulta ANH"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full bg-[#ff7a1a] text-xs text-[#090b0f] hover:bg-[#ff8d3d]"
|
className="w-full text-xs"
|
||||||
onClick={handleMarkAvailableForANH}
|
onClick={handleMarkAvailableForANH}
|
||||||
disabled={isMarkingAvailable}
|
disabled={isMarkingAvailable}
|
||||||
>
|
>
|
||||||
@@ -935,7 +928,7 @@ export default function ANHReportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(insetPanelClass, 'p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<Clock className="h-3.5 w-3.5 text-primary" />
|
<Clock className="h-3.5 w-3.5 text-primary" />
|
||||||
<span className="text-xs font-medium text-muted-foreground">Historial de versiones</span>
|
<span className="text-xs font-medium text-muted-foreground">Historial de versiones</span>
|
||||||
@@ -949,9 +942,9 @@ export default function ANHReportPage() {
|
|||||||
<p className="text-[10px] text-muted-foreground italic py-2">Sin historial de versiones registrado.</p>
|
<p className="text-[10px] text-muted-foreground italic py-2">Sin historial de versiones registrado.</p>
|
||||||
) : (
|
) : (
|
||||||
reportVersions.map((version) => (
|
reportVersions.map((version) => (
|
||||||
<div key={version.id} className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-[9px]">
|
<div key={version.id} className="rounded-md border border-border bg-background p-2 text-[9px]">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-bold text-white">{version.filename}</span>
|
<span className="font-bold text-foreground">{version.filename}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{typeof version.correction_version === 'number' && version.correction_version > 0
|
{typeof version.correction_version === 'number' && version.correction_version > 0
|
||||||
? `Corrección v${version.correction_version}`
|
? `Corrección v${version.correction_version}`
|
||||||
@@ -965,7 +958,7 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(insetPanelClass, 'p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
<Eye className="h-3.5 w-3.5 text-primary" /> Vista Previa (JSON)
|
<Eye className="h-3.5 w-3.5 text-primary" /> Vista Previa (JSON)
|
||||||
@@ -991,7 +984,7 @@ export default function ANHReportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-80 overflow-y-auto rounded-lg border border-[#2a3038] bg-[#090b0f] p-4 font-mono text-sm leading-relaxed custom-scrollbar">
|
<div className="bg-background rounded-md p-4 h-80 overflow-y-auto custom-scrollbar border border-border font-mono text-sm leading-relaxed">
|
||||||
{isPreviewLoading ? (
|
{isPreviewLoading ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<Loader2 className="h-4 w-4 text-primary animate-spin" />
|
<Loader2 className="h-4 w-4 text-primary animate-spin" />
|
||||||
@@ -1004,7 +997,7 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(insetPanelClass, 'p-3')}>
|
<div className="p-3 rounded-lg bg-secondary/40 border border-border">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<Clock className="h-3.5 w-3.5 text-primary" />
|
<Clock className="h-3.5 w-3.5 text-primary" />
|
||||||
<span className="text-xs font-medium text-muted-foreground">Trazabilidad y Auditoría</span>
|
<span className="text-xs font-medium text-muted-foreground">Trazabilidad y Auditoría</span>
|
||||||
@@ -1018,9 +1011,9 @@ export default function ANHReportPage() {
|
|||||||
<p className="text-[10px] text-muted-foreground italic py-2">Sin historial de auditoría registrado.</p>
|
<p className="text-[10px] text-muted-foreground italic py-2">Sin historial de auditoría registrado.</p>
|
||||||
) : (
|
) : (
|
||||||
auditLogs.map((log, index) => (
|
auditLogs.map((log, index) => (
|
||||||
<div key={index} className="space-y-1 rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-[9px]">
|
<div key={index} className="bg-background rounded-md p-2 border border-border text-[9px] space-y-1">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-bold text-white capitalize">
|
<span className="font-bold text-foreground capitalize">
|
||||||
{log.action === "anh_report.generate" ? "Generación Manual" :
|
{log.action === "anh_report.generate" ? "Generación Manual" :
|
||||||
log.action === "anh_report.update" ? "Modificación Borrador" :
|
log.action === "anh_report.update" ? "Modificación Borrador" :
|
||||||
log.action === "anh_report.approve" ? "Aprobado" :
|
log.action === "anh_report.approve" ? "Aprobado" :
|
||||||
@@ -1049,7 +1042,7 @@ export default function ANHReportPage() {
|
|||||||
{canReviewDraft ? (
|
{canReviewDraft ? (
|
||||||
<div className="flex flex-col gap-2 pt-2">
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex w-full items-center justify-center gap-2 bg-emerald-600 text-white hover:bg-emerald-700"
|
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center gap-2"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={isApproving}
|
disabled={isApproving}
|
||||||
>
|
>
|
||||||
@@ -1065,7 +1058,7 @@ export default function ANHReportPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex w-full items-center justify-center gap-2 border-[#2a3038] bg-[#0f1318] text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
|
className="w-full flex items-center justify-center gap-2"
|
||||||
onClick={() => selectedReport && handleDownload(selectedReport)}
|
onClick={() => selectedReport && handleDownload(selectedReport)}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Descargar JSON para revisión
|
<Download className="h-4 w-4" /> Descargar JSON para revisión
|
||||||
@@ -1073,7 +1066,7 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="flex w-full items-center justify-center gap-2 bg-[#ff7a1a] text-[#090b0f] hover:bg-[#ff8d3d]"
|
className="w-full flex items-center justify-center gap-2"
|
||||||
onClick={() => selectedReport && handleDownload(selectedReport)}
|
onClick={() => selectedReport && handleDownload(selectedReport)}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Descargar JSON Final
|
<Download className="h-4 w-4" /> Descargar JSON Final
|
||||||
@@ -1088,7 +1081,7 @@ export default function ANHReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isJsonDialogOpen} onOpenChange={setIsJsonDialogOpen}>
|
<Dialog open={isJsonDialogOpen} onOpenChange={setIsJsonDialogOpen}>
|
||||||
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col gap-4 border-[#2a3038] bg-[#15191e]/95 p-5 text-white backdrop-blur md:p-6">
|
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col gap-4 border-border bg-card/95 p-5 backdrop-blur md:p-6">
|
||||||
<DialogHeader className="pr-8">
|
<DialogHeader className="pr-8">
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
<FileJson className="h-5 w-5 text-primary" /> Revisión completa del JSON ANH
|
<FileJson className="h-5 w-5 text-primary" /> Revisión completa del JSON ANH
|
||||||
@@ -1098,7 +1091,7 @@ export default function ANHReportPage() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 rounded-xl border border-[#2a3038] bg-[#090b0f] p-4">
|
<div className="min-h-0 flex-1 rounded-xl border border-border bg-background p-4">
|
||||||
{isPreviewLoading ? (
|
{isPreviewLoading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
@@ -1117,7 +1110,7 @@ export default function ANHReportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-[#2a3038] pt-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Usa esta vista para revisar el JSON completo con tipografía legible antes de aprobar o descargar.
|
Usa esta vista para revisar el JSON completo con tipografía legible antes de aprobar o descargar.
|
||||||
</p>
|
</p>
|
||||||
@@ -1125,18 +1118,18 @@ export default function ANHReportPage() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" className="border-[#2a3038] bg-[#0f1318] text-[#9aa8ba] hover:bg-[#15191e] hover:text-white" onClick={() => setIsEditing(false)} disabled={isSavingEdit}>Cancelar</Button>
|
<Button variant="outline" onClick={() => setIsEditing(false)} disabled={isSavingEdit}>Cancelar</Button>
|
||||||
<Button className="bg-[#ff7a1a] text-[#090b0f] hover:bg-[#ff8d3d]" onClick={handleSaveEdit} disabled={isSavingEdit}>
|
<Button onClick={handleSaveEdit} disabled={isSavingEdit}>
|
||||||
{isSavingEdit ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
{isSavingEdit ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||||
Guardar Cambios
|
Guardar Cambios
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" className="border-[#2a3038] bg-[#0f1318] text-[#9aa8ba] hover:bg-[#15191e] hover:text-white" onClick={handleStartEdit}>
|
<Button variant="outline" onClick={handleStartEdit}>
|
||||||
<Edit2 className="mr-2 h-4 w-4" /> Editar JSON
|
<Edit2 className="mr-2 h-4 w-4" /> Editar JSON
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleApprove} disabled={isApproving}>
|
<Button onClick={handleApprove} disabled={isApproving}>
|
||||||
{isApproving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
{isApproving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
||||||
Aprobar reporte
|
Aprobar reporte
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
ShieldCheck, Clock, Info, Lock, UserPlus, LogIn,
|
|
||||||
LogOut, AlertTriangle, UserCog, FolderOpen
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils/cn'
|
|
||||||
|
|
||||||
const SAMPLE_AUDIT_LOGS = [
|
|
||||||
{ id: '1', timestamp: '2025-01-15 08:32:11', user: 'admin@omnioil.app', action: 'LOGIN_SUCCESS', resource: 'AUTH', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
{ id: '2', timestamp: '2025-01-15 09:14:05', user: 'admin@omnioil.app', action: 'CREATE_USER', resource: 'USER:ops@petrocol.com', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
{ id: '3', timestamp: '2025-01-15 10:02:44', user: 'admin@omnioil.app', action: 'CREATE_PROJECT', resource: 'PROJECT:Campo Norte Alpha', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
{ id: '4', timestamp: '2025-01-15 11:47:23', user: 'unknown', action: 'LOGIN_FAILURE', resource: 'AUTH', ip: '45.33.99.201', status: 'FAILURE' },
|
|
||||||
{ id: '5', timestamp: '2025-01-15 14:20:01', user: 'admin@omnioil.app', action: 'UPDATE_USER', resource: 'USER:ops@petrocol.com', ip: '181.55.12.34', status: 'SUCCESS' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type ActionIconKey = 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'CREATE_USER' | 'CREATE_PROJECT' | 'UPDATE_USER'
|
|
||||||
|
|
||||||
const ACTION_ICONS: Record<ActionIconKey, React.ElementType> = {
|
|
||||||
LOGIN_SUCCESS: LogIn,
|
|
||||||
LOGIN_FAILURE: AlertTriangle,
|
|
||||||
CREATE_USER: UserPlus,
|
|
||||||
CREATE_PROJECT: FolderOpen,
|
|
||||||
UPDATE_USER: UserCog,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuditPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-xl font-semibold">Centro de Auditoria</h1>
|
|
||||||
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs">
|
|
||||||
Proximamente
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Trazabilidad ISO 27001 · Registro inmutable de eventos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-blue-500/20 bg-blue-950/10">
|
|
||||||
<CardContent className="p-4 flex items-start gap-3">
|
|
||||||
<div className="p-2 rounded-md bg-blue-500/10 shrink-0">
|
|
||||||
<Info className="h-4 w-4 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Registros de Auditoria — Fase de Desarrollo</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
||||||
Los registros se almacenan en PostgreSQL bajo el estandar ISO 27001.
|
|
||||||
Para consultar los registros completos, utiliza el API Explorer.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1.5 mt-2">
|
|
||||||
<Lock className="h-3 w-3 text-emerald-400" />
|
|
||||||
<span className="text-xs text-emerald-400">Cumplimiento ANH Resolucion 0651 · ISO 27001</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
{ label: 'ISO 27001', color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
|
|
||||||
{ label: 'GDPR Ready', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
|
||||||
{ label: 'ANH Res. 0651', color: 'bg-primary/20 text-primary border-primary/30' },
|
|
||||||
{ label: 'Trazabilidad Completa', color: 'bg-violet-500/20 text-violet-400 border-violet-500/30' },
|
|
||||||
].map(({ label, color }) => (
|
|
||||||
<Badge key={label} className={cn('text-xs border', color)}>
|
|
||||||
{label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Muestra de Registros (Datos de Demostracion)</p>
|
|
||||||
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs">DEMO</Badge>
|
|
||||||
</div>
|
|
||||||
<Card className="border-border overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border bg-secondary/30">
|
|
||||||
{['Timestamp', 'Usuario', 'Accion', 'Recurso', 'IP', 'Estado'].map((col) => (
|
|
||||||
<th key={col} className="text-left px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">
|
|
||||||
{col}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{SAMPLE_AUDIT_LOGS.map((log) => {
|
|
||||||
const ActionIcon = ACTION_ICONS[log.action as ActionIconKey] ?? Clock
|
|
||||||
const isFailure = log.status === 'FAILURE'
|
|
||||||
return (
|
|
||||||
<tr key={log.id} className="hover:bg-secondary/20 transition-colors">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{log.timestamp}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs font-medium">{log.user}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ActionIcon className={cn('h-3.5 w-3.5 shrink-0', isFailure ? 'text-red-400' : 'text-primary')} />
|
|
||||||
<span className={cn('font-mono text-xs', isFailure ? 'text-red-400' : 'text-primary')}>{log.action}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">{log.resource}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{log.ip}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge className={cn(
|
|
||||||
'text-xs border-0',
|
|
||||||
isFailure ? 'bg-red-500/20 text-red-400' : 'bg-emerald-500/20 text-emerald-400'
|
|
||||||
)}>
|
|
||||||
{log.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
|
||||||
Los registros en tiempo real estaran disponibles en la proxima version del API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ export interface BillingUsage {
|
|||||||
project_name: string
|
project_name: string
|
||||||
tier: string
|
tier: string
|
||||||
subscription_status: string
|
subscription_status: string
|
||||||
hf_variables: number
|
|
||||||
lf_variables: number
|
|
||||||
active_tags: number
|
active_tags: number
|
||||||
tag_limit: number
|
tag_limit: number
|
||||||
price_per_tag: number
|
price_per_tag: number
|
||||||
@@ -18,44 +16,6 @@ export interface BillingUsage {
|
|||||||
period_start: string
|
period_start: string
|
||||||
period_end: string
|
period_end: string
|
||||||
last_snapshot: string | null
|
last_snapshot: string | null
|
||||||
commercial_profile: CommercialProfileBillingSummary | null
|
|
||||||
formula: OperativeFormulaSummary | null
|
|
||||||
calculation_lines: BillingCalculationLine[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommercialProfileBillingSummary {
|
|
||||||
id: string
|
|
||||||
status: string
|
|
||||||
plan: 'free' | 'operative' | 'enterprise' | string
|
|
||||||
payment_method: string | null
|
|
||||||
payment_terms_days: number | null
|
|
||||||
credit_limit_cop: number | null
|
|
||||||
tg_limit: number | null
|
|
||||||
effective_from: string
|
|
||||||
profile_complete: boolean
|
|
||||||
missing_fields: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OperativeFormulaSummary {
|
|
||||||
hf_tags: number
|
|
||||||
lf_tags: number
|
|
||||||
equivalent_tg: number
|
|
||||||
pbase_cop: number
|
|
||||||
k: number
|
|
||||||
annual_cop: number
|
|
||||||
monthly_cop: number
|
|
||||||
currency: string
|
|
||||||
rounding_policy: string
|
|
||||||
effective_from: string
|
|
||||||
effective_hf_price_cop?: number
|
|
||||||
effective_lf_price_cop?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BillingCalculationLine {
|
|
||||||
kind: string
|
|
||||||
label: string
|
|
||||||
amount_cop: number
|
|
||||||
source: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { act } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import BillingPage from './BillingPage'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
|
||||||
import { getBillingUsage, getInvoices, type BillingUsage, type Invoice } from '../api/billing-api'
|
|
||||||
|
|
||||||
vi.mock('../api/billing-api', () => ({
|
|
||||||
getBillingUsage: vi.fn(),
|
|
||||||
getInvoices: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const COP = (amount: number) =>
|
|
||||||
new Intl.NumberFormat('es-CO', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'COP',
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount)
|
|
||||||
|
|
||||||
const usage: BillingUsage = {
|
|
||||||
project_id: 'project-1',
|
|
||||||
project_name: 'Campo Norte',
|
|
||||||
tier: 'standard',
|
|
||||||
subscription_status: 'active',
|
|
||||||
hf_variables: 8,
|
|
||||||
lf_variables: 4,
|
|
||||||
active_tags: 12,
|
|
||||||
tag_limit: 100,
|
|
||||||
price_per_tag: 110000,
|
|
||||||
min_annual_fee: 0,
|
|
||||||
subtotal_annual: 1320000,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_annual_cop: 1320000,
|
|
||||||
total_monthly_cop: 110000,
|
|
||||||
period_start: '2026-01-01T00:00:00.000Z',
|
|
||||||
period_end: '2026-12-31T00:00:00.000Z',
|
|
||||||
last_snapshot: null,
|
|
||||||
commercial_profile: {
|
|
||||||
id: 'profile-1',
|
|
||||||
status: 'active',
|
|
||||||
plan: 'operative',
|
|
||||||
payment_method: 'wire_transfer',
|
|
||||||
payment_terms_days: 30,
|
|
||||||
credit_limit_cop: 50000000,
|
|
||||||
tg_limit: 120,
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
profile_complete: true,
|
|
||||||
missing_fields: [],
|
|
||||||
},
|
|
||||||
formula: {
|
|
||||||
hf_tags: 8,
|
|
||||||
lf_tags: 4,
|
|
||||||
equivalent_tg: 9.6,
|
|
||||||
pbase_cop: 110000,
|
|
||||||
k: 0.1,
|
|
||||||
annual_cop: 840946,
|
|
||||||
monthly_cop: 70079,
|
|
||||||
currency: 'COP',
|
|
||||||
rounding_policy: 'nearest peso',
|
|
||||||
effective_from: '2026-01-01',
|
|
||||||
effective_hf_price_cop: 87600,
|
|
||||||
effective_lf_price_cop: 35040,
|
|
||||||
},
|
|
||||||
calculation_lines: [
|
|
||||||
{
|
|
||||||
kind: 'operative_formula',
|
|
||||||
label: 'V = HF + (LF * 0.4); annual = V * Pbase * V^-k',
|
|
||||||
amount_cop: 840946,
|
|
||||||
source: 'billing_rules',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoice: Invoice = {
|
|
||||||
id: 'invoice-1',
|
|
||||||
project_id: 'project-1',
|
|
||||||
invoice_number: 'FAC-2026-001',
|
|
||||||
period_start: '2026-01-01T00:00:00.000Z',
|
|
||||||
period_end: '2026-01-31T00:00:00.000Z',
|
|
||||||
active_tags: 9,
|
|
||||||
unit_price: 110000,
|
|
||||||
subtotal: 990000,
|
|
||||||
minimum_applied: false,
|
|
||||||
total_cop: 990000,
|
|
||||||
status: 'sent',
|
|
||||||
due_date: null,
|
|
||||||
paid_at: null,
|
|
||||||
notes: null,
|
|
||||||
created_at: '2026-02-01T00:00:00.000Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAuthStore() {
|
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
|
||||||
useAuthStore.persist.clearStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedAdminSession() {
|
|
||||||
useAuthStore.getState().setSession({
|
|
||||||
accessToken: 'access-token',
|
|
||||||
refreshToken: null,
|
|
||||||
role: 'admin',
|
|
||||||
email: 'admin@omnioil.app',
|
|
||||||
projects: [{ id: 'project-1', name: 'Campo Norte' }],
|
|
||||||
})
|
|
||||||
useAuthStore.getState().setActiveProject('project-1')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushEffects() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BillingPage current usage split', () => {
|
|
||||||
let container: HTMLDivElement
|
|
||||||
let root: ReturnType<typeof createRoot>
|
|
||||||
let queryClient: QueryClient
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetAuthStore()
|
|
||||||
seedAdminSession()
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue(usage)
|
|
||||||
vi.mocked(getInvoices).mockResolvedValue([invoice])
|
|
||||||
|
|
||||||
container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
root = createRoot(container)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount()
|
|
||||||
})
|
|
||||||
queryClient.clear()
|
|
||||||
resetAuthStore()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
container.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function renderBillingPage() {
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<BillingPage />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
await flushEffects()
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders Operative formula values returned by the backend', async () => {
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(getBillingUsage).toHaveBeenCalledWith('project-1')
|
|
||||||
expect(container.textContent).toContain('Plan Operative')
|
|
||||||
expect(container.textContent).toContain('HF tags')
|
|
||||||
expect(container.textContent).toContain('Alta frecuencia en fórmula backend8')
|
|
||||||
expect(container.textContent).toContain('LF tags')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia con factor 0.4 TG4')
|
|
||||||
expect(container.textContent).toContain('TG equivalente 9,6')
|
|
||||||
expect(container.textContent).toContain(`Pbase ${COP(110000)}`)
|
|
||||||
expect(container.textContent).toContain('k 0,1')
|
|
||||||
expect(container.textContent).toContain('V^-k')
|
|
||||||
expect(container.textContent).toContain(COP(840946))
|
|
||||||
expect(container.textContent).toContain(COP(70079))
|
|
||||||
expect(container.textContent).toContain(COP(87600))
|
|
||||||
expect(container.textContent).toContain(COP(35040))
|
|
||||||
expect(container.textContent).not.toContain('25 tags')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders Free as non-collecting with limits and no fake annual or monthly charge', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue({
|
|
||||||
...usage,
|
|
||||||
tier: 'free',
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
price_per_tag: 0,
|
|
||||||
commercial_profile: {
|
|
||||||
...usage.commercial_profile!,
|
|
||||||
plan: 'free',
|
|
||||||
payment_method: 'none',
|
|
||||||
payment_terms_days: 0,
|
|
||||||
credit_limit_cop: 0,
|
|
||||||
tg_limit: 25,
|
|
||||||
},
|
|
||||||
formula: null,
|
|
||||||
calculation_lines: [
|
|
||||||
{
|
|
||||||
kind: 'free_non_collecting',
|
|
||||||
label: 'Free plan does not collect payment',
|
|
||||||
amount_cop: 0,
|
|
||||||
source: 'customer_commercial_profiles',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Plan Free')
|
|
||||||
expect(container.textContent).toContain('Sin cobro real')
|
|
||||||
expect(container.textContent).toContain('No se recauda pago para este perfil comercial.')
|
|
||||||
expect(container.textContent).toContain('TG límite25')
|
|
||||||
expect(container.textContent).toContain('active')
|
|
||||||
expect(container.textContent).toContain('Vigente desde1/1/2026')
|
|
||||||
expect(container.textContent).not.toContain(`12 tags × ${COP(110000)} / tag / año`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders Enterprise configured custom billing terms and override source', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue({
|
|
||||||
...usage,
|
|
||||||
tier: 'enterprise',
|
|
||||||
total_annual_cop: 48000000,
|
|
||||||
total_monthly_cop: 4000000,
|
|
||||||
subtotal_annual: 48000000,
|
|
||||||
price_per_tag: 0,
|
|
||||||
commercial_profile: {
|
|
||||||
...usage.commercial_profile!,
|
|
||||||
plan: 'enterprise',
|
|
||||||
payment_method: 'purchase_order',
|
|
||||||
payment_terms_days: 45,
|
|
||||||
credit_limit_cop: 90000000,
|
|
||||||
tg_limit: 500,
|
|
||||||
},
|
|
||||||
formula: null,
|
|
||||||
calculation_lines: [
|
|
||||||
{
|
|
||||||
kind: 'enterprise_override',
|
|
||||||
label: 'Enterprise override pricing',
|
|
||||||
amount_cop: 48000000,
|
|
||||||
source: 'Contrato marco 2026 aprobado por gerencia',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Plan Enterprise')
|
|
||||||
expect(container.textContent).toContain('Términos configurados')
|
|
||||||
expect(container.textContent).toContain('45 días')
|
|
||||||
expect(container.textContent).toContain(COP(90000000))
|
|
||||||
expect(container.textContent).toContain(COP(48000000))
|
|
||||||
expect(container.textContent).toContain(COP(4000000))
|
|
||||||
expect(container.textContent).toContain('Contrato marco 2026 aprobado por gerencia')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render obsolete active-tags times Pbase messaging for profile-backed billing', async () => {
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).not.toContain(`12 tags × ${COP(110000)} / tag / año`)
|
|
||||||
expect(container.textContent).not.toContain('Total tags activos facturables')
|
|
||||||
expect(container.textContent).toContain('Fuente backend')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps historical invoice rows total-only and does not fabricate HF/LF history', async () => {
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
const invoiceHistory = container.querySelector('[data-testid="invoice-history"]')
|
|
||||||
expect(invoiceHistory?.textContent).toContain('FAC-2026-001')
|
|
||||||
expect(invoiceHistory?.textContent).toContain('9')
|
|
||||||
expect(invoiceHistory?.textContent).toContain(COP(990000))
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('Alta frecuencia')
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('Baja frecuencia')
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('HF')
|
|
||||||
expect(invoiceHistory?.textContent).not.toContain('LF')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders zero current usage from API data without demo or fallback counts', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockResolvedValue({
|
|
||||||
...usage,
|
|
||||||
hf_variables: 0,
|
|
||||||
lf_variables: 0,
|
|
||||||
active_tags: 0,
|
|
||||||
subtotal_annual: 0,
|
|
||||||
total_annual_cop: 0,
|
|
||||||
total_monthly_cop: 0,
|
|
||||||
formula: {
|
|
||||||
...usage.formula!,
|
|
||||||
hf_tags: 0,
|
|
||||||
lf_tags: 0,
|
|
||||||
equivalent_tg: 0,
|
|
||||||
annual_cop: 0,
|
|
||||||
monthly_cop: 0,
|
|
||||||
effective_hf_price_cop: 0,
|
|
||||||
effective_lf_price_cop: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Alta frecuencia (HF)0')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia (LF)0')
|
|
||||||
expect(container.textContent).toContain('Total tags activos0')
|
|
||||||
expect(container.textContent).toContain('Alta frecuencia en fórmula backend0')
|
|
||||||
expect(container.textContent).toContain('Baja frecuencia con factor 0.4 TG0')
|
|
||||||
expect(container.textContent).not.toContain('25 tags')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a usage error state without stale or fabricated counts when usage fails', async () => {
|
|
||||||
vi.mocked(getBillingUsage).mockRejectedValue(new Error('usage unavailable'))
|
|
||||||
|
|
||||||
await renderBillingPage()
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('No se pudo cargar el uso actual de facturación.')
|
|
||||||
expect(container.textContent).not.toContain('Alta frecuencia')
|
|
||||||
expect(container.textContent).not.toContain('Total tags activos')
|
|
||||||
expect(container.textContent).not.toContain('25 tags')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getBillingUsage, getInvoices, type BillingUsage, type Invoice } from '../api/billing-api'
|
import { getBillingUsage, getInvoices, type Invoice } from '../api/billing-api'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Activity, AlertTriangle, Calendar, CreditCard, Gauge, Mail, ReceiptText, Tag, Wallet } from 'lucide-react'
|
import { CreditCard, Tag, Calendar, Mail } from 'lucide-react'
|
||||||
|
|
||||||
const COP = (amount: number) =>
|
const COP = (amount: number) =>
|
||||||
new Intl.NumberFormat('es-CO', {
|
new Intl.NumberFormat('es-CO', {
|
||||||
@@ -16,8 +16,6 @@ const tierLabel: Record<string, string> = {
|
|||||||
starter: 'Starter',
|
starter: 'Starter',
|
||||||
standard: 'Standard',
|
standard: 'Standard',
|
||||||
enterprise: 'Enterprise',
|
enterprise: 'Enterprise',
|
||||||
free: 'Free',
|
|
||||||
operative: 'Operative',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
||||||
@@ -27,40 +25,22 @@ const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceStatusConfig: Record<string, { label: string; className: string }> = {
|
const invoiceStatusConfig: Record<string, { label: string; className: string }> = {
|
||||||
pending: { label: 'Pendiente', className: 'bg-orange-500/15 text-orange-300 border-orange-400/40' },
|
pending: { label: 'Pendiente', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' },
|
||||||
sent: { label: 'Enviada', className: 'bg-sky-500/15 text-sky-300 border-sky-400/35' },
|
sent: { label: 'Enviada', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||||
paid: { label: 'Pagada', className: 'bg-emerald-500/15 text-emerald-300 border-emerald-400/35' },
|
paid: { label: 'Pagada', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
|
||||||
overdue: { label: 'Vencida', className: 'bg-red-500/15 text-red-300 border-red-400/35' },
|
overdue: { label: 'Vencida', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
|
||||||
cancelled: { label: 'Cancelada', className: 'bg-slate-500/15 text-slate-300 border-slate-400/30' },
|
cancelled: { label: 'Cancelada', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function InvoiceStatusBadge({ status }: { status: Invoice['status'] }) {
|
function InvoiceStatusBadge({ status }: { status: Invoice['status'] }) {
|
||||||
const cfg = invoiceStatusConfig[status] ?? invoiceStatusConfig.pending!
|
const cfg = invoiceStatusConfig[status] ?? invoiceStatusConfig.pending!
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${cfg.className}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${cfg.className}`}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellCard = 'border-[#2a3038] bg-[#11151a] shadow-[0_0_0_1px_rgba(255,255,255,0.02)]'
|
|
||||||
const panelCard = 'border-[#2d333c] bg-[#12161c] shadow-[0_18px_50px_rgba(0,0,0,0.18)]'
|
|
||||||
const mutedLabel = 'text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
|
|
||||||
const tableHeader = 'px-4 py-3 text-left text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
|
|
||||||
const tableCell = 'px-4 py-3 text-sm text-slate-300'
|
|
||||||
|
|
||||||
const planLabel = (plan: string) => tierLabel[plan] ?? plan
|
|
||||||
const formatDate = (value: string) => new Date(value).toLocaleDateString('es-CO', { timeZone: 'UTC' })
|
|
||||||
const formatNumber = (value: number) => new Intl.NumberFormat('es-CO', { maximumFractionDigits: 2 }).format(value)
|
|
||||||
|
|
||||||
function profilePlan(usage: BillingUsage) {
|
|
||||||
return usage.commercial_profile?.plan ?? usage.tier
|
|
||||||
}
|
|
||||||
|
|
||||||
function billingSource(usage: BillingUsage) {
|
|
||||||
return usage.commercial_profile ? 'Fuente backend · perfil comercial' : 'Legado · suscripción sin perfil comercial'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
||||||
|
|
||||||
@@ -92,398 +72,195 @@ 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>
|
||||||
<div className="flex flex-col gap-3 border-b border-[#242a32] pb-4 lg:flex-row lg:items-end lg:justify-between">
|
<h1 className="text-2xl font-black text-white">Facturación & Plan</h1>
|
||||||
<div>
|
<p className="text-gray-400 text-sm mt-1">{usage.project_name}</p>
|
||||||
<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">
|
|
||||||
Consola de facturación
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
|
||||||
<Badge variant={tierVariant[plan] ?? 'secondary'} className="border border-orange-400/25 bg-orange-500/10 px-3 py-1 text-orange-200">
|
|
||||||
Plan {planName}
|
|
||||||
</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">
|
|
||||||
{usage.subscription_status}
|
|
||||||
</span>
|
|
||||||
</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>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className={shellCard}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<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" />
|
|
||||||
</span>
|
|
||||||
</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>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{!commercialProfile && (
|
|
||||||
<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">
|
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[760px] border-collapse text-sm">
|
|
||||||
<thead className="bg-[#0d1116]">
|
|
||||||
<tr className="border-b border-[#2b313a]">
|
|
||||||
<th className={tableHeader}>Concepto</th>
|
|
||||||
<th className={tableHeader}>Detalle</th>
|
|
||||||
<th className={`${tableHeader} text-right`}>Cant.</th>
|
|
||||||
<th className={`${tableHeader} text-right`}>Unitario</th>
|
|
||||||
<th className={`${tableHeader} text-right`}>Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#20262e]">
|
|
||||||
{commercialProfile && plan === 'free' && (
|
|
||||||
<tr className="hover:bg-white/[0.025]">
|
|
||||||
<td className={`${tableCell} font-semibold text-white`}>Sin cobro real</td>
|
|
||||||
<td className={tableCell}>No se recauda pago para este perfil comercial.</td>
|
|
||||||
<td className={`${tableCell} text-right tabular-nums`}>{usage.active_tags}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono text-slate-500`}>{COP(0)}</td>
|
|
||||||
<td className={`${tableCell} text-right font-mono font-semibold text-emerald-300`}>{COP(0)}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{commercialProfile && plan === 'operative' && formula && (
|
|
||||||
<>
|
|
||||||
<tr className="hover:bg-white/[0.025]">
|
|
||||||
<td className={`${tableCell} font-semibold text-white`}>HF tags</td>
|
|
||||||
<td className={tableCell}>Alta frecuencia en fórmula backend</td>
|
|
||||||
<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 className="border-t border-[#2b313a] bg-[#0d1116] px-4 py-4">
|
|
||||||
<div className="ml-auto w-full max-w-md space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-slate-400">Subtotal anual</span>
|
|
||||||
<span className="font-mono font-semibold text-white">{COP(displayedAnnualCop)}</span>
|
|
||||||
</div>
|
|
||||||
{usage.minimum_applied && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-orange-300">Mínimo aplicado</span>
|
|
||||||
<span className="font-mono font-semibold text-orange-300">{COP(usage.min_annual_fee)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between border-t border-[#303741] pt-3">
|
|
||||||
<span className="text-base font-semibold text-white">Total anual</span>
|
|
||||||
<span className="font-mono text-2xl font-semibold tracking-[-0.025em] text-orange-400">{COP(displayedAnnualCop)}</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>
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
<CardContent className="p-0" data-testid="invoice-history">
|
|
||||||
{invoicesQuery.isLoading ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-400 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
) : invoices.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-slate-500">Sin facturas generadas aún.</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[760px] text-sm">
|
|
||||||
<thead className="bg-[#0d1116]">
|
|
||||||
<tr className="border-b border-[#2b313a]">
|
|
||||||
<th className={tableHeader}>Factura</th>
|
|
||||||
<th className={tableHeader}>Periodo</th>
|
|
||||||
<th className={`${tableHeader} text-right`}>Tags</th>
|
|
||||||
<th className={`${tableHeader} text-right`}>Total</th>
|
|
||||||
<th className={tableHeader}>Estado</th>
|
|
||||||
<th className={tableHeader}>Emitida</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#20262e]">
|
|
||||||
{invoices.map((inv) => (
|
|
||||||
<tr key={inv.id} className="text-slate-300 transition-colors hover:bg-white/[0.025]">
|
|
||||||
<td className="px-4 py-3 font-mono text-xs font-semibold text-white">{inv.invoice_number}</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-slate-400">
|
|
||||||
{new Date(inv.period_start).toLocaleDateString('es-CO')} - {new Date(inv.period_end).toLocaleDateString('es-CO')}
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside 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">
|
|
||||||
<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>
|
|
||||||
<p className="text-slate-500">Términos</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
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
facturacion@omnioil.app
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Plan summary */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{/* Plan card */}
|
||||||
|
<Card className="bg-gray-900 border-gray-700 col-span-1">
|
||||||
|
<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>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tags card */}
|
||||||
|
<Card className="bg-gray-900 border-gray-700 col-span-1">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
Tags Activos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-black text-white">
|
||||||
|
{usage.active_tags}
|
||||||
|
{usage.tag_limit > 0 && (
|
||||||
|
<span className="text-sm text-gray-400 font-normal"> / {usage.tag_limit}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Period card */}
|
||||||
|
<Card className="bg-gray-900 border-gray-700 col-span-1">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Período de Facturación
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-gray-300">
|
||||||
|
Desde{' '}
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{new Date(usage.period_start).toLocaleDateString('es-CO')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-300 mt-1">
|
||||||
|
Hasta{' '}
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{new Date(usage.period_end).toLocaleDateString('es-CO')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost breakdown */}
|
||||||
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white font-bold">Resumen de Costos</CardTitle>
|
||||||
|
</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>
|
||||||
|
{usage.minimum_applied && (
|
||||||
|
<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-amber-400">Cargo mínimo aplicado</span>
|
||||||
|
<span className="text-amber-400 font-semibold">{COP(usage.min_annual_fee)} / año</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-gray-700 pt-3 flex justify-between items-center">
|
||||||
|
<span className="text-white font-bold">Total anual</span>
|
||||||
|
<span className="text-2xl font-black text-amber-400">{COP(usage.total_annual_cop)}</span>
|
||||||
|
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoice history */}
|
||||||
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white font-bold">Historial de Facturas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{invoicesQuery.isLoading ? (
|
||||||
|
<div className="py-8 flex justify-center">
|
||||||
|
<div className="animate-spin h-6 w-6 border-2 border-amber-400 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : invoicesQuery.data?.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm text-center py-6">Sin facturas generadas aún.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 border-b border-gray-700">
|
||||||
|
<th className="text-left py-2 pr-4">Factura</th>
|
||||||
|
<th className="text-left py-2 pr-4">Período</th>
|
||||||
|
<th className="text-right py-2 pr-4">Tags</th>
|
||||||
|
<th className="text-right py-2 pr-4">Total</th>
|
||||||
|
<th className="text-left py-2 pr-4">Estado</th>
|
||||||
|
<th className="text-left py-2">Fecha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800">
|
||||||
|
{invoicesQuery.data?.map((inv) => (
|
||||||
|
<tr key={inv.id} className="text-gray-300 hover:bg-gray-800/50">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{inv.invoice_number}</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">
|
||||||
|
{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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact card */}
|
||||||
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
|
<CardContent className="pt-4 flex items-center gap-3">
|
||||||
|
<Mail className="h-5 w-5 text-amber-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Para facturación y preguntas sobre tu plan:</p>
|
||||||
|
<a
|
||||||
|
href="mailto:facturacion@omnioil.app"
|
||||||
|
className="text-amber-400 hover:text-amber-300 font-semibold text-sm"
|
||||||
|
>
|
||||||
|
facturacion@omnioil.app
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user