Compare commits
323 Commits
fac9ab3fe0
...
Actualizac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3277d41227 | ||
| 9ea2f48088 | |||
| 36cd78b870 | |||
| 7a38146744 | |||
| af2fc3c6f4 | |||
| 94fd169e20 | |||
| cf2448b439 | |||
|
|
9db196eea2 | ||
|
|
e5547e0134 | ||
|
|
368d9f4928 | ||
|
|
5d2cdd8ff5 | ||
|
|
76c8ad109f | ||
| 7e9c317a5f | |||
| 588a0aab09 | |||
| 0d91b49dec | |||
| 52fc377755 | |||
| 867ebceb4f | |||
| 99f73f7e2a | |||
| e5a67cab90 | |||
| 1f6743590e | |||
|
|
bc01767106 | ||
|
|
342bd08bdd | ||
|
|
6f6ed2fa36 | ||
|
|
2a18ada082 | ||
|
|
0ce9d2d550 | ||
|
|
fb017396f0 | ||
|
|
f16b112978 | ||
|
|
3e6e3c559d | ||
|
|
fae8173199 | ||
|
|
5948998f3d | ||
|
|
3815268b8a | ||
|
|
50bee181f6 | ||
|
|
da35b72f8f | ||
|
|
2908ac0c66 | ||
|
|
43b8349447 | ||
|
|
9b6b488c8a | ||
|
|
319ceaae30 | ||
|
|
8e0a2b78bb | ||
|
|
50674b3078 | ||
|
|
d47360f86c | ||
|
|
6cc6b4cc1a | ||
|
|
7121ca9f47 | ||
|
|
6a8cc55978 | ||
|
|
d1adbe436a | ||
|
|
895abef058 | ||
|
|
10e5993d2b | ||
|
|
ed6312f3a8 | ||
|
|
f5cea71798 | ||
|
|
74517e2612 | ||
|
|
b084cf4f93 | ||
|
|
21a43f189e | ||
|
|
2a166396a0 | ||
|
|
df718085cf | ||
|
|
ef069b2ab9 | ||
|
|
f55a022eb7 | ||
|
|
b2bf8d21c8 | ||
|
|
a18f059af1 | ||
|
|
f0ed6166a2 | ||
|
|
1a704b5197 | ||
|
|
1aa40c9067 | ||
|
|
39b83dbbe8 | ||
|
|
db3304ed43 | ||
|
|
91c7f12e25 | ||
|
|
240f8ac87a | ||
|
|
744e43c93f | ||
|
|
eb46661d3f | ||
|
|
87d32acb30 | ||
|
|
3ce893f24b | ||
|
|
959c89b5d3 | ||
|
|
f38ca951f7 | ||
|
|
4ccc1bbf90 | ||
|
|
df31ea1041 | ||
|
|
e661caa514 | ||
|
|
d9daa6e6e4 | ||
|
|
5b68e2c80e | ||
|
|
5b58da0f18 | ||
|
|
70d1922906 | ||
|
|
d97fc76f16 | ||
|
|
eaaf368053 | ||
|
|
40cd3ece33 | ||
|
|
333c3b3b21 | ||
|
|
5cd46f75a0 | ||
|
|
f250ad11de | ||
| c8a8089243 | |||
|
|
565b0c0a61 | ||
| 9b806d3fec | |||
|
|
64c42a02e4 | ||
|
|
338967b949 | ||
|
|
b331b05d55 | ||
|
|
6dd7754206 | ||
| 30a16e5b6c | |||
| e229f0ae50 | |||
| 47d31837e9 | |||
| 04ef63537c | |||
|
|
ce9ad1f8f0 | ||
|
|
d9c01647ef | ||
|
|
e615b06f7b | ||
|
|
9559eb4e7d | ||
|
|
311a5422a9 | ||
|
|
132be581f5 | ||
|
|
c43642218e | ||
|
|
91181431aa | ||
| ac411b1893 | |||
|
|
d70efd6b5e | ||
| 49431911fc | |||
|
|
f28cb4dad7 | ||
| baa906001e | |||
|
|
f9151dd9ba | ||
|
|
f009c43bd2 | ||
|
|
113be509ee | ||
|
|
439b80dca8 | ||
| 51e27e49f4 | |||
|
|
948b372f40 | ||
|
|
3c6cc42216 | ||
|
|
675b3033a4 | ||
|
|
b5357a3d0c | ||
| 761a4ab5a4 | |||
|
|
89907fbb58 | ||
|
|
3dfe4c5659 | ||
|
|
741034ac39 | ||
|
|
8cfd7a085b | ||
|
|
88dc61512f | ||
|
|
18d8ffa375 | ||
|
|
c1254566c1 | ||
| d5c768d9c3 | |||
| b7f1873504 | |||
|
|
3721fa7d12 | ||
|
|
d486c07f87 | ||
|
|
e6e091895e | ||
|
|
9ee1e11608 | ||
|
|
4125ec627b | ||
|
|
0d6c006297 | ||
|
|
6c234f60fd | ||
|
|
3f850db0ae | ||
|
|
14b0a03b14 | ||
|
|
f18bfe65c3 | ||
|
|
9fdf049ea8 | ||
|
|
13562d3298 | ||
|
|
c71c58c5aa | ||
|
|
9f13d4f274 | ||
|
|
9e65a0b447 | ||
|
|
edcd02e469 | ||
|
|
88fb707a66 | ||
|
|
ea035b4b60 | ||
|
|
be3fcf1f28 | ||
|
|
a97f968be2 | ||
|
|
0ada878809 | ||
|
|
fd4220c98d | ||
|
|
0329296ea5 | ||
|
|
bfda385620 | ||
|
|
690f7ff48d | ||
|
|
e7c142f4cb | ||
|
|
5734abe176 | ||
|
|
6e755e2b56 | ||
|
|
4c7e092ffa | ||
|
|
095ec2ff25 | ||
|
|
66229ab050 | ||
|
|
83e067b334 | ||
|
|
c12deda9f9 | ||
|
|
c45862f49d | ||
|
|
eec83ea63d | ||
|
|
5ce407d5b1 | ||
|
|
e1f8d968a8 | ||
|
|
940f58ecdd | ||
|
|
4126ea9aa0 | ||
|
|
b7c348b655 | ||
|
|
509c91321f | ||
|
|
2928da900f | ||
|
|
0698ca2471 | ||
|
|
686704b658 | ||
|
|
a777a99367 | ||
|
|
13aea702f3 | ||
|
|
b43c41d2ae | ||
|
|
0e43805154 | ||
|
|
2ecca522cf | ||
|
|
37780092e3 | ||
|
|
7e6993fc72 | ||
|
|
8035f5005e | ||
|
|
7fb03039bd | ||
|
|
b90b91f7db | ||
|
|
fb0764bf9a | ||
|
|
86c85c4110 | ||
|
|
8b572457e0 | ||
|
|
bd473719bb | ||
|
|
b4dcae280a | ||
|
|
0c510e120b | ||
|
|
92675d9616 | ||
|
|
932932e9e6 | ||
|
|
c9d7f82b2d | ||
|
|
5e385667e8 | ||
|
|
1652669aad | ||
|
|
10fce1119f | ||
|
|
1d1b13b478 | ||
|
|
d7dc59b7f3 | ||
|
|
9cfddf0be6 | ||
|
|
4f91382b5e | ||
|
|
e29210b16c | ||
|
|
b8ab916c8d | ||
|
|
d2a3143c85 | ||
|
|
473631d329 | ||
|
|
70a2282716 | ||
|
|
fe0176da4a | ||
|
|
2bc9eb3120 | ||
|
|
0bb57a0749 | ||
|
|
3ca1d31ede | ||
|
|
3be117cb29 | ||
|
|
2da824f1a6 | ||
|
|
89590121a6 | ||
|
|
83f4c54d2c | ||
|
|
e45b05d288 | ||
|
|
e6a9a40ff8 | ||
|
|
14aa7c252a | ||
|
|
2e42230f54 | ||
|
|
4217e8ec39 | ||
|
|
9cdafcd262 | ||
|
|
1c08e44f45 | ||
|
|
08e097d640 | ||
|
|
330fb7cb30 | ||
|
|
5301f72e28 | ||
|
|
06c81822d0 | ||
|
|
02f24f27ce | ||
|
|
28d1a7d930 | ||
|
|
1c57abd97a | ||
|
|
fb0c172bb5 | ||
|
|
6f486a038b | ||
|
|
78e8ecb55a | ||
|
|
9f3d7212ee | ||
|
|
c81ed907f7 | ||
|
|
f90fb552fc | ||
|
|
83988cfcde | ||
|
|
22b39aadd3 | ||
|
|
9c878a310b | ||
|
|
2d9ef90b09 | ||
|
|
e6d884ae95 | ||
|
|
2dd1975b8d | ||
|
|
fa1be83665 | ||
|
|
f19a13def1 | ||
|
|
2c3dd8e8bd | ||
|
|
47d6a4e48d | ||
|
|
7ccadb0272 | ||
|
|
154f183341 | ||
| d1647ddbcf | |||
|
|
d130ee578f | ||
|
|
6afacdc7ce | ||
|
|
4566a57e94 | ||
|
|
79dd41ba6f | ||
|
|
77bcde08ef | ||
|
|
56bb76fdb2 | ||
|
|
7a774a70fc | ||
|
|
2a53ae7e9e | ||
|
|
c97ab61512 | ||
|
|
d6bbc66676 | ||
|
|
997b6b0da8 | ||
|
|
555aef226e | ||
|
|
f9af6a552e | ||
|
|
60ad7ca44f | ||
|
|
de213a22f0 | ||
|
|
93307c3b8f | ||
|
|
edc773b367 | ||
|
|
f43a3f202d | ||
|
|
a379c8dd59 | ||
|
|
02151e56dd | ||
|
|
db259b5dbe | ||
|
|
8359ae9a06 | ||
|
|
2430419257 | ||
|
|
ba0b0c633f | ||
|
|
7d2013cedf | ||
|
|
660092a8ff | ||
|
|
0092d20e35 | ||
|
|
1c9059a0ea | ||
|
|
a0cb973078 | ||
|
|
c92e0b5c06 | ||
|
|
0949f19c9d | ||
|
|
3bde9096ab | ||
| 63ef77d1ce | |||
| dae5e8bf00 | |||
|
|
a524e52a19 | ||
|
|
6f780cf22e | ||
|
|
0ad8fc05c1 | ||
|
|
977e500bd6 | ||
|
|
7efef41179 | ||
|
|
accefcd375 | ||
|
|
f83d530c18 | ||
|
|
58de3dc1f9 | ||
|
|
80403eeb20 | ||
|
|
dcf72cef79 | ||
|
|
58ebb1adf0 | ||
|
|
e56dac1269 | ||
|
|
98a09289a6 | ||
|
|
b34213c9c8 | ||
|
|
6e9fdc0f17 | ||
| a36b06bbf8 | |||
| 84f5c42e5b | |||
| 4f1f530ce1 | |||
|
|
dd4511cdfc | ||
|
|
55a3c9895d | ||
|
|
ee0ed8800a | ||
|
|
801c78fd57 | ||
|
|
9e5b69f3a2 | ||
|
|
11f4757fe0 | ||
|
|
980fbcc3b3 | ||
|
|
f3fd71e286 | ||
|
|
f0ce0a67cc | ||
| 7aec3b67fc | |||
| 0551d41475 | |||
| dade0608a8 | |||
| ebb165bbff | |||
|
|
6d0c432043 | ||
|
|
c72868f65b | ||
|
|
39eea9d6a4 | ||
|
|
d00db12ec0 | ||
| e83d88c599 | |||
|
|
e26af8ef37 | ||
|
|
0b73ef5c49 | ||
|
|
f5ba0df791 | ||
|
|
12c3564e01 | ||
|
|
a4f1fa137d | ||
|
|
952514784e | ||
|
|
ab000b29a9 | ||
|
|
487b075493 | ||
|
|
671a5339a9 | ||
| f910768719 | |||
| 30eff47bec |
14
.clippy.toml
Normal file
14
.clippy.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 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,6 +58,7 @@ 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
|
||||||
@@ -66,6 +67,10 @@ 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
Normal file
63
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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
|
||||||
16
.pre-commit-config.yaml
Normal file
16
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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,6 +971,8 @@ dependencies = [
|
|||||||
"csv",
|
"csv",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"governor",
|
"governor",
|
||||||
|
"hex",
|
||||||
|
"hmac 0.12.1",
|
||||||
"json-generator",
|
"json-generator",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -1168,9 +1170,11 @@ 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",
|
||||||
@@ -1179,6 +1183,7 @@ dependencies = [
|
|||||||
"shared-lib",
|
"shared-lib",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1920,7 +1925,7 @@ dependencies = [
|
|||||||
"tray-icon",
|
"tray-icon",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
"winit",
|
"winit",
|
||||||
"winres",
|
"winres",
|
||||||
"zstd",
|
"zstd",
|
||||||
@@ -2045,14 +2050,17 @@ 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",
|
||||||
@@ -3241,7 +3249,7 @@ dependencies = [
|
|||||||
"image-webp",
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png 0.18.1",
|
"png",
|
||||||
"qoi",
|
"qoi",
|
||||||
"ravif",
|
"ravif",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3405,16 +3413,19 @@ 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",
|
||||||
@@ -3816,9 +3827,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.2"
|
version = "0.19.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
|
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -3830,9 +3841,9 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.17.16",
|
"png",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4647,19 +4658,6 @@ 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"
|
||||||
@@ -4851,6 +4849,27 @@ 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"
|
||||||
@@ -6438,14 +6457,17 @@ 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",
|
||||||
@@ -6994,6 +7016,16 @@ 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"
|
||||||
@@ -7004,19 +7036,22 @@ 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.22.2"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76b42a907631429634f9f57ef72f9e7aec3e12e74050636138acb3752ecb8df3"
|
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -7028,7 +7063,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png",
|
||||||
"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"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||||
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,6 +42,7 @@ 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
|
FROM deps AS builder_base
|
||||||
# Copy the rest of the source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# --- Stage 3: Landing build ---
|
# --- Stage 3: Landing build ---
|
||||||
FROM builder AS builder-landing
|
FROM builder_base 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 AS builder-admin
|
FROM builder_base 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,7 +48,8 @@ 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 AS builder-console
|
FROM builder_base 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
|
||||||
@@ -58,7 +59,8 @@ 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 AS builder-pwa
|
FROM builder_base 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,6 +53,10 @@ 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
Normal file
76
Makefile
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
.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
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL}
|
- MINIO_ENDPOINT_URL=http://minio:9000
|
||||||
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
||||||
@@ -113,6 +113,8 @@ 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
|
||||||
@@ -134,6 +136,8 @@ 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
|
||||||
@@ -157,6 +161,8 @@ 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
|
||||||
@@ -269,7 +275,7 @@ services:
|
|||||||
- MQTT_PORT=1883
|
- MQTT_PORT=1883
|
||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL}
|
- MINIO_ENDPOINT_URL=http://minio:9000
|
||||||
- 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}
|
- AWS_REGION=${AWS_REGION}
|
||||||
@@ -281,6 +287,8 @@ 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
|
||||||
@@ -317,6 +325,7 @@ 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
|
||||||
@@ -345,6 +354,44 @@ 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
|
||||||
@@ -376,3 +423,5 @@ volumes:
|
|||||||
installer_output:
|
installer_output:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
|
loki_data:
|
||||||
|
alertmanager_data:
|
||||||
|
|||||||
@@ -1,132 +1,115 @@
|
|||||||
# 📊 Simulador Multiprotocolo Unificado (Modbus TCP & Siemens S7)
|
# Simulador de Carga Modbus TCP
|
||||||
|
|
||||||
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**.
|
`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.
|
||||||
|
|
||||||
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.
|
> 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.
|
||||||
|
|
||||||
---
|
## Servicios expuestos
|
||||||
|
|
||||||
## 🔌 Puertos de Exposición y Servicios
|
| Servicio | Protocolo | Endpoint | Descripción |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
El simulador expone tres servicios en puertos distintos sobre la interfaz de red local (`localhost` / `0.0.0.0`):
|
## Instalación
|
||||||
|
|
||||||
| Servicio | Protocolo | Puerto | Descripción |
|
Usá Python 3.8+ y fijá la versión de `pymodbus`:
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **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 # En Linux/WSL/macOS
|
source venv/bin/activate # Linux/WSL/macOS
|
||||||
# o: venv\Scripts\activate en Windows
|
# o: venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
pip install "pymodbus==3.6.9"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Dependencias de Python
|
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.
|
||||||
Instala las dependencias necesarias:
|
|
||||||
```bash
|
|
||||||
pip install pymodbus python-snap7
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Requisito del Sistema para Siemens S7 (`snap7`)
|
## Ejecución
|
||||||
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:
|
|
||||||
|
|
||||||
#### En Linux (Ubuntu / WSL / Debian):
|
Desde la raíz del repositorio:
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comportamiento al iniciar:
|
Al iniciar, el script:
|
||||||
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.
|
||||||
|
|
||||||
## 📊 Mapeo y Estructura de Datos Simulados
|
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.
|
||||||
|
|
||||||
Los valores cambian dinámicamente cada 5 segundos simulando condiciones industriales en tiempo real:
|
## Contrato Modbus
|
||||||
|
|
||||||
### 1. Modbus TCP (Puerto `5020`, ID de Esclavo por defecto: `0x00`)
|
- Host recomendado para clientes locales: `127.0.0.1`.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
* **Holding Registers (FC 3):**
|
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.
|
||||||
* `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`.
|
|
||||||
|
|
||||||
### 2. Siemens S7 (Puerto `1102`, Rack 0, Slot 1)
|
## API HTTP
|
||||||
|
|
||||||
* **Data Block 1 (DB1):**
|
`GET http://127.0.0.1:8085/api/data` devuelve un snapshot con esta forma:
|
||||||
* `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-23 09:30:15",
|
"timestamp": "2026-06-25 15:00:00",
|
||||||
"modbus": {
|
"summary": {
|
||||||
"running": true,
|
"total": 1000,
|
||||||
"holding_registers": [4832, 1289, 7891, 5421, 3312],
|
"hf": 400,
|
||||||
"input_registers": [12015, 8431, 14201]
|
"lf": 600,
|
||||||
|
"wells": 5,
|
||||||
|
"changes_last_cycle": 400,
|
||||||
|
"modbus_running": true
|
||||||
},
|
},
|
||||||
"s7": {
|
"wells": [
|
||||||
"running": true,
|
{ "code": "POZO-01", "unit_id": 1, "tags": 200, "hf": 80, "lf": 120 }
|
||||||
"db1_dbw0": 18451,
|
],
|
||||||
"db1_dbw2": 11342
|
"sample": [
|
||||||
},
|
{ "well": "POZO-01", "alias": "Presion_Cabezal_01", "address": "HR:0", "value": 1042, "unit": "psi" }
|
||||||
"http": {
|
]
|
||||||
"running": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## CSV de importación
|
||||||
|
|
||||||
## 🎨 Visualización en Navegador (Dashboard Web)
|
El simulador genera `tests/simulators/variables_import.csv` al arrancar. Ese archivo es salida generada y está ignorado por Git; no debe commitearse.
|
||||||
|
|
||||||
Abre tu navegador web favorito y dirígete a:
|
Headers esperados:
|
||||||
|
|
||||||
👉 **`http://localhost:8085`** o **`http://localhost:8085/dashboard`**
|
```text
|
||||||
|
WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl
|
||||||
|
```
|
||||||
|
|
||||||
### Características del Dashboard:
|
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.
|
||||||
- **Diseño Premium:** Estética oscura moderna con efectos de traslucidez (glassmorphism) y paleta de colores curada para el sector energético.
|
|
||||||
- **Gráficas en Vivo:** Tendencias dinámicas e interactivas representadas mediante *Chart.js* con actualizaciones continuas cada 5 segundos.
|
## Verificación segura
|
||||||
- **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.
|
|
||||||
- **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.
|
Comandos útiles para validar el contrato sin depender del backend completo:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|||||||
98
docs/architecture/telemetry-origin-contract.md
Normal file
98
docs/architecture/telemetry-origin-contract.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 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.
|
||||||
15
infrastructure/monitoring/alertmanager.yml
Normal file
15
infrastructure/monitoring/alertmanager.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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,3 +6,10 @@ 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
|
||||||
|
|||||||
39
infrastructure/monitoring/loki-config.yml
Normal file
39
infrastructure/monitoring/loki-config.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
30
infrastructure/monitoring/prometheus-rules.yml
Normal file
30
infrastructure/monitoring/prometheus-rules.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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,6 +2,14 @@ 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:
|
||||||
@@ -9,6 +17,26 @@ 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']
|
||||||
|
|||||||
21
infrastructure/monitoring/promtail-config.yml
Normal file
21
infrastructure/monitoring/promtail-config.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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
Executable file → Normal file
0
infrastructure/mosquitto/entrypoint.sh
Executable file → Normal file
@@ -44,6 +44,9 @@ persistence true
|
|||||||
persistence_location /mosquitto/data
|
persistence_location /mosquitto/data
|
||||||
|
|
||||||
# Límite de tamaño de paquetes MQTT
|
# Límite de tamaño de paquetes MQTT
|
||||||
# 256KB: suficiente para payloads de provisioning comprimidos y telemetría
|
# 2MB: suficiente para ProjectConfig sin comprimir durante deploys locales/grandes
|
||||||
# (rumqttc también está configurado con 256KB)
|
# y payloads de telemetría de simulador con cientos de variables por dispositivo.
|
||||||
message_size_limit 262144
|
# Mosquitto 2.x anuncia `max_packet_size` a clientes MQTT v5; mantener ambos
|
||||||
|
# evita que clientes rumqttc sigan viendo el límite default de 256KB.
|
||||||
|
max_packet_size 2097152
|
||||||
|
message_size_limit 2097152
|
||||||
|
|||||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
25
scripts/dev-watch.ps1
Normal file
25
scripts/dev-watch.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/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,3 +44,5 @@ 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,10 +52,15 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin backend-api
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY . .
|
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
||||||
# Copiar caché del cacher
|
COPY --from=cacher /app /app
|
||||||
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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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();
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
-- 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
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 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,6 +99,19 @@ 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()
|
||||||
@@ -113,3 +126,37 @@ 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,10 +390,16 @@ 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!(
|
|
||||||
email.as_str(),
|
let reviewers = std::env::var("ACCESS_REQUEST_REVIEWER_EMAILS")
|
||||||
"w.farfan@omnioil.app" | "h.ortegon@omnioil.app"
|
.unwrap_or_default();
|
||||||
) {
|
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(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
services/backend-api/src/billing_rules.rs
Normal file
108
services/backend-api/src/billing_rules.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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,8 +1,43 @@
|
|||||||
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");
|
||||||
|
|
||||||
@@ -10,9 +45,7 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
|
|||||||
let max_retries = 10;
|
let max_retries = 10;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match PgPoolOptions::new()
|
match default_pool_options()
|
||||||
.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, Utc};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -83,6 +83,71 @@ 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,
|
||||||
@@ -112,6 +177,7 @@ 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#"
|
||||||
@@ -127,7 +193,8 @@ const ACCESS_REQUEST_SELECT: &str = r#"
|
|||||||
sub.trial_ends_at AS trial_ends_at,
|
sub.trial_ends_at AS trial_ends_at,
|
||||||
inv.expires_at AS invitation_expires_at,
|
inv.expires_at AS invitation_expires_at,
|
||||||
inv.consumed_at AS invitation_consumed_at,
|
inv.consumed_at AS invitation_consumed_at,
|
||||||
inv.revoked_at AS invitation_revoked_at
|
inv.revoked_at AS invitation_revoked_at,
|
||||||
|
profile.commercial_profile AS commercial_profile
|
||||||
FROM access_requests ar
|
FROM access_requests ar
|
||||||
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
LEFT JOIN users u ON u.id = ar.provisioned_user_id
|
||||||
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
|
||||||
@@ -145,17 +212,195 @@ 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);
|
||||||
|
|
||||||
@@ -166,6 +411,169 @@ 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()
|
||||||
@@ -393,6 +801,138 @@ 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,
|
||||||
@@ -417,12 +957,48 @@ 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 !matches!(request.status.as_str(), "pending" | "in_review") {
|
if !request_can_be_provisioned(&request.status) {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"La solicitud no existe o ya fue cerrada".to_string(),
|
"La solicitud requiere clasificación comercial aprobada antes de provisionarse"
|
||||||
|
.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)")
|
||||||
@@ -559,6 +1135,18 @@ 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 {
|
||||||
@@ -1208,8 +1796,9 @@ 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, invitation_email_delivery_update, project_defaults,
|
approval_provisioning_audit_actions, classification_missing_fields,
|
||||||
provisioning_audit_metadata, resend_delivery_kind, should_revoke_invitation_on_resend,
|
invitation_email_delivery_update, project_defaults, provisioning_audit_metadata,
|
||||||
|
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};
|
||||||
@@ -1419,4 +2008,174 @@ 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,6 +105,14 @@ 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,
|
||||||
@@ -452,7 +460,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', 'in_review', 'approved')
|
AND status IN ('pending', 'pending_classification', 'in_review', 'approved')
|
||||||
)"#,
|
)"#,
|
||||||
)
|
)
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
@@ -467,7 +475,7 @@ pub async fn request_access(
|
|||||||
return Ok(duplicate_access_request_response());
|
return Ok(duplicate_access_request_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id = sqlx::query_scalar(
|
let request_id_result: Result<uuid::Uuid, sqlx::Error> = 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)
|
||||||
@@ -492,7 +500,7 @@ pub async fn request_access(
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let request_id = match request_id {
|
let request_id = match request_id_result {
|
||||||
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) {
|
||||||
@@ -517,42 +525,29 @@ pub async fn request_access(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let approved_request = if let Ok(row) =
|
sqlx::query(REQUEST_ACCESS_DRAFT_PROFILE_SQL)
|
||||||
crate::handlers::access_requests::auto_approve_access_request(&pool, request_id, &headers)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
row
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE access_requests
|
|
||||||
SET status = 'rejected', rejection_reason = 'auto_approval_failed', updated_at = NOW()
|
|
||||||
WHERE id = $1 AND status = 'pending'"#,
|
|
||||||
)
|
|
||||||
.bind(request_id)
|
.bind(request_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await
|
||||||
tracing::error!(request_id = %request_id, "Access request auto-approval failed");
|
.map_err(|error| {
|
||||||
return Err(AuthError::Internal(anyhow::anyhow!(
|
tracing::error!(request_id = %request_id, error = %error, "Commercial profile creation failed");
|
||||||
"Error al aprobar la prueba gratuita"
|
AuthError::Internal(anyhow::anyhow!("Error al registrar perfil comercial"))
|
||||||
)));
|
})?;
|
||||||
};
|
|
||||||
|
|
||||||
if approved_request.invitation_email_status.as_deref() == Some("failed") {
|
sqlx::query(REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL)
|
||||||
return Ok((
|
.bind(request_id)
|
||||||
StatusCode::ACCEPTED,
|
.execute(&pool)
|
||||||
Json(AccessRequestResponse {
|
.await
|
||||||
status: "approved_email_failed",
|
.map_err(|error| {
|
||||||
message: "Tu prueba gratuita fue aprobada, pero no pudimos enviar el correo de activación. Escríbenos a soporte@omnioil.app.",
|
tracing::error!(request_id = %request_id, error = %error, "Access request classification status update failed");
|
||||||
}),
|
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud"))
|
||||||
)
|
})?;
|
||||||
.into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::ACCEPTED,
|
||||||
Json(AccessRequestResponse {
|
Json(AccessRequestResponse {
|
||||||
status: "approved",
|
status: "pending_classification",
|
||||||
message: "Tu prueba gratuita fue aprobada. Revisa tu correo para activar la cuenta.",
|
message: "Recibimos tu solicitud. El equipo comercial la revisará antes de activar el acceso.",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
@@ -981,6 +976,34 @@ 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,6 +11,10 @@ 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 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,6 +66,8 @@ 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,
|
||||||
@@ -73,6 +79,47 @@ 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)]
|
||||||
@@ -103,6 +150,50 @@ 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>,
|
||||||
@@ -137,7 +228,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, 90000, 4000000, 'active')
|
VALUES ($1, 'starter', 50, 110000, 4000000, 'active')
|
||||||
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
|
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING *"#,
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
@@ -161,6 +252,380 @@ 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
|
||||||
@@ -193,21 +658,11 @@ pub async fn get_billing_usage(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Count active tags
|
// Count current billable variables from real persisted HF/LF sources.
|
||||||
let tag_count: i64 = match sqlx::query_scalar(
|
let counts = match count_billing_variables(&pool, project_id).await {
|
||||||
r#"SELECT COUNT(*)
|
Ok(counts) => counts,
|
||||||
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 tags: {}", e);
|
tracing::error!("DB error counting billing variables: {}", e);
|
||||||
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -230,26 +685,47 @@ pub async fn get_billing_usage(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
|
|
||||||
let (subtotal, minimum_applied, total) =
|
let profile = match fetch_commercial_profile_for_billing(&pool, project_id).await {
|
||||||
calculate_billing(tag_count, sub.price_per_tag, sub.min_annual_fee);
|
Ok(profile) => profile,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("DB error fetching commercial profile: {}", e);
|
||||||
|
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Json(BillingUsage {
|
if let Some(profile) = profile {
|
||||||
|
let billing = match build_profile_billing_response(&profile, counts) {
|
||||||
|
Ok(billing) => billing,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(?error, "Commercial billing calculation failed");
|
||||||
|
return err(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Billing calculation error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Json(build_profile_billing_usage_response(
|
||||||
project_id,
|
project_id,
|
||||||
project_name: project.name,
|
project.name,
|
||||||
tier: sub.tier,
|
&sub,
|
||||||
subscription_status: sub.status,
|
counts,
|
||||||
active_tags: tag_count,
|
billing,
|
||||||
tag_limit: sub.tag_limit,
|
|
||||||
price_per_tag: sub.price_per_tag,
|
|
||||||
min_annual_fee: sub.min_annual_fee,
|
|
||||||
subtotal_annual: subtotal,
|
|
||||||
minimum_applied,
|
|
||||||
total_annual_cop: total,
|
|
||||||
total_monthly_cop: total / 12,
|
|
||||||
period_start: sub.current_period_start,
|
|
||||||
period_end: sub.current_period_end,
|
|
||||||
last_snapshot,
|
last_snapshot,
|
||||||
})
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let billing = calculate_current_billing(counts, sub.price_per_tag, sub.min_annual_fee);
|
||||||
|
|
||||||
|
Json(build_billing_usage_response(
|
||||||
|
project_id,
|
||||||
|
project.name,
|
||||||
|
&sub,
|
||||||
|
counts,
|
||||||
|
billing,
|
||||||
|
last_snapshot,
|
||||||
|
))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,3 +992,289 @@ 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,6 +11,7 @@ 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 {
|
||||||
@@ -22,7 +23,8 @@ 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
|
||||||
@@ -43,6 +45,7 @@ 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,5 +1,6 @@
|
|||||||
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},
|
||||||
@@ -34,22 +35,14 @@ 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()))?;
|
||||||
|
|
||||||
let access = sqlx::query(
|
well_access::ensure_asset_access(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
&pool,
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
user_id,
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
params.asset_id,
|
||||||
|
"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",
|
||||||
)
|
)
|
||||||
@@ -69,22 +62,14 @@ 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()))?;
|
||||||
|
|
||||||
let access = sqlx::query(
|
well_access::ensure_asset_access(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
&pool,
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
user_id,
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
req.asset_id,
|
||||||
|
"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("SELECT id FROM edge_projects WHERE id = $1")
|
let project_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM edge_projects WHERE id = $1"
|
||||||
|
)
|
||||||
.bind(agent_id)
|
.bind(agent_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)))?;
|
||||||
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 =
|
let api_base = std::env::var("API_BASE_URL")
|
||||||
std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
|
.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,17 +94,13 @@ 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).map_err(|e| {
|
let payload_bytes = serde_json::to_vec(&command_payload)
|
||||||
crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e))
|
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
state
|
state.mqtt_client
|
||||||
.mqtt_client
|
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", 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: {})",
|
||||||
@@ -124,6 +120,8 @@ 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>,
|
||||||
@@ -132,8 +130,6 @@ 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,6 +1,7 @@
|
|||||||
//! 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},
|
||||||
@@ -33,26 +34,12 @@ 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 access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
let assets = sqlx::query_as::<_, EdgeAsset>(well_access::project_assets_sql())
|
||||||
|
.bind(project_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(project_id)
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if access.is_none() {
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Sin acceso a los activos de este proyecto".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let assets = sqlx::query_as::<_, EdgeAsset>(
|
|
||||||
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,47 @@ 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(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
deployment_id: Uuid,
|
||||||
|
error_message: &str,
|
||||||
|
) {
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
r#"UPDATE edge_deployments
|
||||||
|
SET status = 'FAILED', error_message = $1
|
||||||
|
WHERE id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(error_message)
|
||||||
|
.bind(deployment_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to mark deployment {} as FAILED after build dispatch error: {}",
|
||||||
|
deployment_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to mark project {} installer as FAILED after build dispatch error: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/edge/projects/:project_id/deployments
|
/// GET /api/edge/projects/:project_id/deployments
|
||||||
///
|
///
|
||||||
@@ -113,10 +150,11 @@ pub async fn trigger_build(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI
|
// 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI
|
||||||
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
|
sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// ─── Dispatch Build Request via MQTT ──────────────────────────────
|
// ─── Dispatch Build Request via MQTT ──────────────────────────────
|
||||||
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
@@ -127,23 +165,34 @@ pub async fn trigger_build(
|
|||||||
timestamp: chrono::Utc::now(),
|
timestamp: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(payload) = serde_json::to_string(&event) {
|
let payload = serde_json::to_string(&event).map_err(|e| {
|
||||||
let mqtt_clone = mqtt.clone();
|
let message = format!("Failed to serialize ProjectCreatedEvent: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"📡 Enviando señal de construcción para despliegue {}: {}",
|
"📡 Enviando señal de construcción para despliegue {}: {}",
|
||||||
deployment.id,
|
deployment.id,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
tokio::spawn(async move {
|
if let Err(e) = mqtt
|
||||||
let _ = mqtt_clone
|
|
||||||
.publish(
|
.publish(
|
||||||
"omnioil/events/project_created",
|
"omnioil/events/project_created",
|
||||||
rumqttc::QoS::AtLeastOnce,
|
rumqttc::QoS::AtLeastOnce,
|
||||||
false,
|
false,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
});
|
{
|
||||||
|
let message = format!("MQTT publish failed for installer build request: {}", e);
|
||||||
|
tracing::error!(
|
||||||
|
"{} (project={}, deployment={})",
|
||||||
|
message,
|
||||||
|
project_id,
|
||||||
|
deployment.id
|
||||||
|
);
|
||||||
|
mark_build_dispatch_failed(&pool, project_id, deployment.id, &message).await;
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((StatusCode::ACCEPTED, Json(deployment)))
|
Ok((StatusCode::ACCEPTED, Json(deployment)))
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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;
|
||||||
@@ -1090,19 +1089,22 @@ pub async fn rebuild_installer(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
mqtt.publish(
|
if let Err(e) = mqtt
|
||||||
|
.publish(
|
||||||
"omnioil/events/project_created",
|
"omnioil/events/project_created",
|
||||||
rumqttc::QoS::AtLeastOnce,
|
rumqttc::QoS::AtLeastOnce,
|
||||||
false,
|
false,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
(
|
let message = format!("MQTT Publish failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
|
||||||
format!("MQTT Publish failed: {}", e),
|
.bind(id)
|
||||||
)
|
.execute(&pool)
|
||||||
})?;
|
.await;
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, message));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🛠️ Reconstrucción de instalador solicitada para proyecto: {}",
|
"🛠️ Reconstrucción de instalador solicitada para proyecto: {}",
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ 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;
|
use crate::handlers::{audit_helper, well_access};
|
||||||
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, Row};
|
use sqlx::{PgPool, Postgres};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -19,6 +19,7 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,115 @@ 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>,
|
||||||
@@ -37,22 +147,14 @@ 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()))?;
|
||||||
|
|
||||||
let access = sqlx::query(
|
well_access::ensure_asset_access(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
&pool,
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
user_id,
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
params.asset_id,
|
||||||
|
"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",
|
||||||
)
|
)
|
||||||
@@ -73,46 +175,58 @@ 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_row = sqlx::query(
|
let project_id = well_access::ensure_asset_access(
|
||||||
r#"SELECT ea.project_id FROM edge_assets ea
|
&pool,
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
user_id,
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
req.asset_id,
|
||||||
|
"Sin permiso para registrar movimientos en este activo",
|
||||||
)
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let client_id = normalize_client_id(req.client_id)?;
|
||||||
|
|
||||||
|
if let Some(client_id) = client_id.as_deref() {
|
||||||
|
let existing = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
|
find_existing_movement_by_client_id_sql(),
|
||||||
|
)
|
||||||
|
.bind(client_id)
|
||||||
.bind(req.asset_id)
|
.bind(req.asset_id)
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let project_id: Uuid = match project_row {
|
if let Some(movement) = existing {
|
||||||
Some(row) => row.get("project_id"),
|
return Ok((StatusCode::OK, Json(movement)));
|
||||||
None => {
|
}
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Sin permiso para registrar movimientos en este activo".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let details = serde_json::json!({
|
let details = movement_details(req.observaciones, client_id.clone(), &claims.sub);
|
||||||
"observaciones": req.observaciones,
|
|
||||||
"created_by": claims.sub
|
|
||||||
});
|
|
||||||
|
|
||||||
let movement: OperationMovement = sqlx::query_as::<Postgres, OperationMovement>(
|
let inserted = sqlx::query_as::<Postgres, OperationMovement>(insert_movement_sql())
|
||||||
r#"INSERT INTO operation_movements (
|
|
||||||
asset_id, movement_type, start_time, end_time, volumen_neto, details
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING *"#,
|
|
||||||
)
|
|
||||||
.bind(req.asset_id)
|
.bind(req.asset_id)
|
||||||
.bind(req.movement_type)
|
.bind(req.movement_type)
|
||||||
.bind(req.start_time)
|
.bind(req.start_time)
|
||||||
.bind(req.end_time)
|
.bind(req.end_time)
|
||||||
.bind(req.volumen_neto)
|
.bind(req.volumen_neto)
|
||||||
.bind(details)
|
.bind(details)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(movement) = inserted else {
|
||||||
|
let Some(client_id) = client_id.as_deref() else {
|
||||||
|
return Err(AppError::Database(sqlx::Error::RowNotFound));
|
||||||
|
};
|
||||||
|
|
||||||
|
let movement = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
|
find_existing_movement_by_client_id_sql(),
|
||||||
|
)
|
||||||
|
.bind(client_id)
|
||||||
|
.bind(req.asset_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
return Ok((StatusCode::OK, Json(movement)));
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Auditoría ────────────────────────────────────────────────────────────
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
audit_helper::log_audit(
|
audit_helper::log_audit(
|
||||||
&pool,
|
&pool,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! - `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},
|
||||||
@@ -12,38 +14,79 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
|
use shared_lib::models::{
|
||||||
|
PostTelemetryRequest, TelemetryRecord, TelemetryResponse, TelemetrySource,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
|
fn app_error_to_tuple(error: AppError) -> (StatusCode, String) {
|
||||||
|
match error {
|
||||||
|
AppError::NotFound(entity) => (StatusCode::NOT_FOUND, format!("{} no encontrado", entity)),
|
||||||
|
AppError::Forbidden(message) => (StatusCode::FORBIDDEN, message),
|
||||||
|
AppError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
|
||||||
|
AppError::Database(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Error interno de base de datos".to_string(),
|
||||||
|
),
|
||||||
|
AppError::Internal(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Error interno del servidor".to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/telemetry — Official LF telemetry channel for PWA/manual clients.
|
||||||
///
|
///
|
||||||
/// Los agentes de campo envían telemetría exclusivamente por MQTT (`omnioil/telemetry/{id}`),
|
/// SCADA/HF agents send telemetry by MQTT (`omnioil/telemetry/{project_id}`), never by HTTP.
|
||||||
/// 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)?;
|
||||||
sqlx::query(
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
r#"INSERT INTO telemetry_raw (time, asset_id, data)
|
(
|
||||||
VALUES ($1, $2, $3)
|
StatusCode::UNAUTHORIZED,
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING"#,
|
"User ID inválido en token".to_string(),
|
||||||
)
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
well_access::ensure_asset_access(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
req.asset_id,
|
||||||
|
"Access denied to this asset's telemetry",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(app_error_to_tuple)?;
|
||||||
|
|
||||||
|
let result = sqlx::query(post_telemetry_insert_sql())
|
||||||
.bind(time)
|
.bind(time)
|
||||||
.bind(req.asset_id)
|
.bind(req.asset_id)
|
||||||
.bind(&req.data)
|
.bind(&req.data)
|
||||||
|
.bind(source)
|
||||||
|
.bind(&req.client_id)
|
||||||
|
.bind(user_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,6 +94,8 @@ 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)))
|
||||||
@@ -108,7 +153,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
|
r#"SELECT time, asset_id, data, source, client_id
|
||||||
FROM telemetry_raw
|
FROM telemetry_raw
|
||||||
WHERE asset_id = $1
|
WHERE asset_id = $1
|
||||||
AND time >= $2
|
AND time >= $2
|
||||||
@@ -125,6 +170,80 @@ 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,13 +7,14 @@
|
|||||||
//!
|
//!
|
||||||
//! 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 Bearer token
|
//! │ verifica firma HMAC (ts+sig)
|
||||||
//! │ 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 autenticación y puede auditar.
|
//! Todo el tráfico pasa por el backend API que valida la firma HMAC de la URL.
|
||||||
|
//! La firma usa AGENT_DOWNLOAD_SECRET y expira a los 5 minutos.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -22,12 +23,97 @@ 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)]
|
||||||
@@ -36,6 +122,10 @@ 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)]
|
||||||
@@ -44,6 +134,10 @@ 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)]
|
||||||
@@ -67,7 +161,7 @@ fn err(status: StatusCode, msg: &str) -> Response {
|
|||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/edge/update-info?version=<current>[&target=<pin>]
|
/// GET /api/edge/update-info?version=<current>[&target=<pin>][&ts=<unix>&sig=<hex>]
|
||||||
///
|
///
|
||||||
/// 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.
|
||||||
@@ -75,6 +169,19 @@ 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,
|
||||||
@@ -128,14 +235,10 @@ 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, no a MinIO directamente
|
// 5. download_url apunta al endpoint del BACKEND con firma HMAC, 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 = format!(
|
let download_url = generate_signed_url(&api_base, &latest.to_string(), "msi");
|
||||||
"{}/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);
|
||||||
|
|
||||||
@@ -149,7 +252,7 @@ pub async fn get_update_info(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>
|
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>&ts=<unix>&sig=<hex>
|
||||||
///
|
///
|
||||||
/// 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.
|
||||||
@@ -157,8 +260,23 @@ 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 = params.version.trim_start_matches('v');
|
let version_clean = 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());
|
||||||
|
|||||||
337
services/backend-api/src/handlers/well_access.rs
Normal file
337
services/backend-api/src/handlers/well_access.rs
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
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,13 +2,16 @@ 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,26 +1,16 @@
|
|||||||
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, auth, crypto, db, handlers, metrics, notifier,
|
AppState, TelemetryEvent, api_version, crypto, db, handlers, metrics, mqtt_loop,
|
||||||
rate_limiter, watchdog,
|
rate_limiter, watchdog, ws,
|
||||||
};
|
};
|
||||||
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();
|
||||||
@@ -30,7 +20,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())
|
.with(tracing_subscriber::fmt::layer().json().flatten_event(false))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Starting Backend API...");
|
tracing::info!("Starting Backend API...");
|
||||||
@@ -118,15 +108,15 @@ async fn main() {
|
|||||||
let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4());
|
let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4());
|
||||||
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
||||||
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
||||||
// Aumentar el límite de paquetes: por defecto rumqttc usa 10KB, insuficiente
|
// Large simulator imports can produce ProjectConfig deploy requests above
|
||||||
// para payloads de provisioning y telemetría comprimida (256KB es seguro).
|
// 400KB before the build-orchestrator compresses the agent deploy signal.
|
||||||
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
|
mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
|
||||||
|
|
||||||
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
|
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
|
||||||
mqtt_options.set_credentials(u, p);
|
mqtt_options.set_credentials(u, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
let (mqtt_client, 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 {
|
||||||
@@ -135,256 +125,18 @@ async fn main() {
|
|||||||
tx: tx.clone(),
|
tx: tx.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn MQTT event loop para procesar mensajes
|
// Spawn MQTT event loop (extraído a mqtt_loop.rs)
|
||||||
let tx_relay = tx.clone();
|
let mqtt_client_for_loop = mqtt_client.clone();
|
||||||
let prov_pool = pool.clone();
|
let mqtt_pool = pool.clone();
|
||||||
let prov_mqtt_client = mqtt_client.clone();
|
let mqtt_tx = tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
mqtt_loop::run_event_loop(
|
||||||
match eventloop.poll().await {
|
eventloop,
|
||||||
Ok(notification) => {
|
mqtt_client_for_loop,
|
||||||
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) =
|
mqtt_pool,
|
||||||
notification
|
mqtt_tx,
|
||||||
{
|
|
||||||
let topic = publish.topic.as_str();
|
|
||||||
|
|
||||||
if topic.starts_with("omnioil/status/") {
|
|
||||||
// ── Estado inmediato del agente (LWT o reconexión) ─────────────
|
|
||||||
// Permite que la UI muestre "offline" en segundos, no minutos.
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/status/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(msg) =
|
|
||||||
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let status =
|
|
||||||
msg["status"].as_str().unwrap_or("offline").to_string();
|
|
||||||
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
|
|
||||||
{
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE edge_projects
|
|
||||||
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|
|
||||||
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
|
|
||||||
WHERE id = $2"#,
|
|
||||||
)
|
)
|
||||||
.bind(&status)
|
|
||||||
.bind(project_uuid)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
|
||||||
// Notificar UI en tiempo real
|
|
||||||
let event = TelemetryEvent {
|
|
||||||
project_id: Arc::from(project_id_str.to_lowercase()),
|
|
||||||
payload: Arc::from(
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "agent_status",
|
|
||||||
"status": status,
|
|
||||||
"timestamp": msg["timestamp"]
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let _ = tx_ref.send(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/health/") {
|
|
||||||
// ── Health report periódico del agente ─────────────────────────
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/health/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(report) = serde_json::from_slice::<
|
|
||||||
shared_lib::mqtt_messages::HealthReport,
|
|
||||||
>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE edge_projects SET last_health_report = $1 WHERE id = $2",
|
|
||||||
)
|
|
||||||
.bind(serde_json::to_value(&report).unwrap_or_default())
|
|
||||||
.bind(report.project_id)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Relay a UI
|
|
||||||
let event = TelemetryEvent {
|
|
||||||
project_id: Arc::from(project_id_str.to_lowercase()),
|
|
||||||
payload: Arc::from(
|
|
||||||
serde_json::to_string(&report).unwrap_or_default(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let _ = tx_ref.send(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/logs/") {
|
|
||||||
// ── Logs del agente → insertar en DB ──────────────────────────
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(entry) = serde_json::from_slice::<
|
|
||||||
shared_lib::mqtt_messages::AgentLogEntry,
|
|
||||||
>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"INSERT INTO agent_logs
|
|
||||||
(project_id, level, category, message, context, agent_hostname, timestamp)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
|
|
||||||
)
|
|
||||||
.bind(entry.project_id)
|
|
||||||
.bind(&entry.level)
|
|
||||||
.bind(&entry.category)
|
|
||||||
.bind(&entry.message)
|
|
||||||
.bind(entry.context.unwrap_or(serde_json::json!({})))
|
|
||||||
.bind(&entry.agent_hostname)
|
|
||||||
.bind(entry.timestamp)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/alarms/") {
|
|
||||||
// ── Alarma MQTT → dispatch notificaciones ─────────────────────
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let tx_ref = tx_relay.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
let project_id_str = topic
|
|
||||||
.strip_prefix("omnioil/alarms/")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(event) = serde_json::from_slice::<
|
|
||||||
shared_lib::mqtt_messages::AlarmEvent,
|
|
||||||
>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let alarm = notifier::AlarmNotification {
|
|
||||||
project_id: event.project_id,
|
|
||||||
asset_id: None,
|
|
||||||
variable: event
|
|
||||||
.variable_alias
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
value: event.current_value.unwrap_or(0.0),
|
|
||||||
alarm_type: format!("{:?}", event.alarm_type),
|
|
||||||
message: event.message.unwrap_or_default(),
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
};
|
|
||||||
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
|
|
||||||
project_id_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/opcua-scan/") {
|
|
||||||
// ── OPC UA scan result from agent → store in DB ───────────────
|
|
||||||
let pool_ref = prov_pool.clone();
|
|
||||||
let payload_bytes = publish.payload.to_vec();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Ok(result) =
|
|
||||||
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
|
|
||||||
{
|
|
||||||
let scan_id_str = result
|
|
||||||
.get("scan_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let status = result
|
|
||||||
.get("status")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("failed");
|
|
||||||
let error_msg = result.get("error").and_then(|v| v.as_str());
|
|
||||||
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
r#"UPDATE opcua_scan_results
|
|
||||||
SET status = $1, result = $2, error_message = $3
|
|
||||||
WHERE id = $4"#,
|
|
||||||
)
|
|
||||||
.bind(status)
|
|
||||||
.bind(&result)
|
|
||||||
.bind(error_msg)
|
|
||||||
.bind(scan_id)
|
|
||||||
.execute(&pool_ref)
|
|
||||||
.await;
|
|
||||||
tracing::info!(
|
|
||||||
"✅ OPC UA scan result stored: {} ({})",
|
|
||||||
scan_id,
|
|
||||||
status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if topic.starts_with("omnioil/") {
|
|
||||||
// ── 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
|
||||||
@@ -585,6 +337,7 @@ 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()
|
||||||
@@ -634,7 +387,13 @@ 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("/setup/register", post(handlers::auth::setup_register));
|
.route(
|
||||||
|
"/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(
|
||||||
@@ -668,6 +427,14 @@ 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(
|
||||||
@@ -682,6 +449,14 @@ 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),
|
||||||
@@ -709,6 +484,14 @@ 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),
|
||||||
@@ -914,9 +697,8 @@ 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)
|
// WebSocket Stream (Relay de Telemetría) — extraído a ws.rs
|
||||||
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
|
.merge(ws::routes())
|
||||||
.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,
|
||||||
@@ -939,298 +721,3 @@ 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,10 +7,30 @@ 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"
|
||||||
@@ -25,10 +45,30 @@ 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)
|
||||||
@@ -43,4 +83,20 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
215
services/backend-api/src/mqtt_loop.rs
Normal file
215
services/backend-api/src/mqtt_loop.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
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,11 +173,10 @@ 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")
|
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
|
||||||
.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_else(|_| "-4336874579".to_string())
|
std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
destination.to_string()
|
destination.to_string()
|
||||||
};
|
};
|
||||||
@@ -195,7 +194,12 @@ async fn send_telegram_alarm(
|
|||||||
• *Tipo:* {}\n\
|
• *Tipo:* {}\n\
|
||||||
• *Mensaje:* {}\n\
|
• *Mensaje:* {}\n\
|
||||||
• *Timestamp:* {}",
|
• *Timestamp:* {}",
|
||||||
alarm.variable, alarm.variable, alarm.value, alarm.alarm_type, alarm.message, alarm.timestamp
|
alarm.variable,
|
||||||
|
alarm.variable,
|
||||||
|
alarm.value,
|
||||||
|
alarm.alarm_type,
|
||||||
|
alarm.message,
|
||||||
|
alarm.timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
@@ -270,15 +274,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!("No se pudo enviar alerta de sistema por correo: ADMIN_EMAIL, SMTP_FROM y SMTP_USER no están configurados");
|
tracing::warn!(
|
||||||
|
"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")
|
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
|
||||||
.unwrap_or_else(|_| "8626426348:AAFNXkEMA1jgj8Z3QFeptpY8mYYg_6BHFxQ".to_string());
|
let chat_id = std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default();
|
||||||
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");
|
||||||
@@ -327,7 +331,10 @@ 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!(inst.is_some(), "Debería haberse registrado el envío de la alerta");
|
assert!(
|
||||||
|
inst.is_some(),
|
||||||
|
"Debería haberse registrado el envío de la alerta"
|
||||||
|
);
|
||||||
inst.unwrap()
|
inst.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -337,13 +344,19 @@ 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!(first_instant, second_instant, "El segundo envío inmediato no debería actualizar el timestamp debido al cooldown");
|
assert_eq!(
|
||||||
|
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(context.to_string(), Instant::now() - Duration::from_secs(650));
|
map.insert(
|
||||||
|
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)
|
||||||
@@ -352,7 +365,10 @@ 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!(third_instant > first_instant, "El envío después del cooldown debería actualizar el timestamp");
|
assert!(
|
||||||
|
third_instant > first_instant,
|
||||||
|
"El envío después del cooldown debería actualizar el timestamp"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ 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...");
|
||||||
|
|
||||||
@@ -55,22 +66,11 @@ async fn check_telemetry_silence(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
|
for spec in telemetry_silence_specs() {
|
||||||
let rows = sqlx::query(
|
let window = format_window_interval(spec.window);
|
||||||
r#"
|
let rows = sqlx::query(telemetry_silence_select_sql())
|
||||||
SELECT p.id, p.name
|
.bind(spec.frequency_class)
|
||||||
FROM edge_projects p
|
.bind(&window)
|
||||||
WHERE p.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM telemetry_raw t
|
|
||||||
WHERE t.project_id = p.id
|
|
||||||
AND t.time > NOW() - INTERVAL '10 minutes'
|
|
||||||
)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -78,31 +78,31 @@ async fn check_telemetry_silence(
|
|||||||
let proj_id: Uuid = row.get("id");
|
let proj_id: Uuid = row.get("id");
|
||||||
let proj_name: String = row.get("name");
|
let proj_name: String = row.get("name");
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
|
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
|
||||||
proj_name
|
source = spec.source,
|
||||||
|
frequency_class = spec.frequency_class,
|
||||||
);
|
);
|
||||||
|
|
||||||
let already_alerted = sqlx::query(
|
let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
|
||||||
r#"
|
|
||||||
SELECT 1 FROM telemetry_alarms
|
|
||||||
WHERE project_id = $1
|
|
||||||
AND alarm_type = 'TELEMETRY_SILENCE'
|
|
||||||
AND timestamp > NOW() - INTERVAL '1 hour'
|
|
||||||
LIMIT 1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(proj_id)
|
.bind(proj_id)
|
||||||
|
.bind(spec.alarm_type)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if already_alerted.is_none() {
|
if already_alerted.is_none() {
|
||||||
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
|
tracing::warn!(
|
||||||
|
source = spec.source,
|
||||||
|
frequency_class = spec.frequency_class,
|
||||||
|
"⚠️ Detectado silencio prolongado en project: {}",
|
||||||
|
proj_name
|
||||||
|
);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
|
VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
|
||||||
)
|
)
|
||||||
.bind(proj_id)
|
.bind(proj_id)
|
||||||
|
.bind(spec.alarm_type)
|
||||||
.bind(&msg)
|
.bind(&msg)
|
||||||
.bind(Utc::now())
|
.bind(Utc::now())
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -111,19 +111,90 @@ async fn check_telemetry_silence(
|
|||||||
let alarm = AlarmNotification {
|
let alarm = AlarmNotification {
|
||||||
project_id: proj_id,
|
project_id: proj_id,
|
||||||
asset_id: None,
|
asset_id: None,
|
||||||
variable: "TELEMETRY_SILENCE".to_string(),
|
variable: spec.alarm_type.to_string(),
|
||||||
value: 0.0,
|
value: 0.0,
|
||||||
alarm_type: "TELEMETRY_SILENCE".to_string(),
|
alarm_type: spec.alarm_type.to_string(),
|
||||||
message: msg,
|
message: msg,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
};
|
};
|
||||||
notifier::dispatch(pool, &alarm, tx).await;
|
notifier::dispatch(pool, &alarm, tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
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
|
||||||
|
FROM edge_projects p
|
||||||
|
WHERE p.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM telemetry_raw t
|
||||||
|
WHERE t.project_id = p.id
|
||||||
|
AND CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1
|
||||||
|
AND t.time > NOW() - ($2::text)::interval
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn telemetry_silence_recent_alarm_sql() -> &'static str {
|
||||||
|
r#"
|
||||||
|
SELECT 1 FROM telemetry_alarms
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND (
|
||||||
|
alarm_type = $2
|
||||||
|
OR ($2 = 'TELEMETRY_SILENCE_HF' AND alarm_type = 'TELEMETRY_SILENCE')
|
||||||
|
)
|
||||||
|
AND timestamp > NOW() - INTERVAL '1 hour'
|
||||||
|
LIMIT 1
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
async fn check_unhealthy_agents(
|
async fn check_unhealthy_agents(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
@@ -430,6 +501,36 @@ 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(
|
||||||
|
|||||||
303
services/backend-api/src/ws.rs
Normal file
303
services/backend-api/src/ws.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
99
services/backend-api/tests/billing_rules.rs
Normal file
99
services/backend-api/tests/billing_rules.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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,3 +20,6 @@ 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,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin build-orchestrator
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY . .
|
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
||||||
# Copiar caché del cacher
|
COPY --from=cacher /app /app
|
||||||
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,7 +558,10 @@ 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!("⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}", binary_path);
|
tracing::warn!(
|
||||||
|
"⚠️ No se encontró el binario genérico de Linux para el agente en: {:?}",
|
||||||
|
binary_path
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +627,12 @@ 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!("🚀 Subiendo binario Linux del agente v{} a MinIO (bucket: {}, key: {})...", version, bucket_name, key);
|
tracing::info!(
|
||||||
|
"🚀 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,4 +1,5 @@
|
|||||||
mod installer_worker;
|
mod installer_worker;
|
||||||
|
mod metrics;
|
||||||
mod publisher;
|
mod publisher;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -6,8 +7,13 @@ 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 DEPLOY_CONTROL_TOPIC: &str = "omnioil/control/deploy/#";
|
||||||
|
|
||||||
/// Estado compartido del orquestador.
|
/// Estado compartido del orquestador.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct OrchestratorState {
|
pub struct OrchestratorState {
|
||||||
@@ -15,6 +21,104 @@ pub struct OrchestratorState {
|
|||||||
pub mqtt_client: rumqttc::AsyncClient,
|
pub mqtt_client: rumqttc::AsyncClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct PendingInstallerBuild {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
client: String,
|
||||||
|
deployment_id: Option<Uuid>,
|
||||||
|
installer_status: Option<String>,
|
||||||
|
deployment_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subscribe_topics(client: &rumqttc::AsyncClient) -> Result<(), rumqttc::ClientError> {
|
||||||
|
client
|
||||||
|
.subscribe(PROJECT_CREATED_TOPIC, rumqttc::QoS::AtLeastOnce)
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.subscribe(DEPLOY_CONTROL_TOPIC, rumqttc::QoS::AtLeastOnce)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(
|
||||||
|
"✅ MQTT subscriptions active: {}, {}",
|
||||||
|
PROJECT_CREATED_TOPIC,
|
||||||
|
DEPLOY_CONTROL_TOPIC
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recover_pending_installer_builds(
|
||||||
|
state: OrchestratorState,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let pending = sqlx::query_as::<_, PendingInstallerBuild>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.client,
|
||||||
|
p.installer_status,
|
||||||
|
latest.id AS deployment_id,
|
||||||
|
latest.status AS deployment_status
|
||||||
|
FROM edge_projects p
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, status
|
||||||
|
FROM edge_deployments
|
||||||
|
WHERE project_id = p.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON true
|
||||||
|
WHERE p.installer_status = 'BUILDING'
|
||||||
|
ORDER BY p.created_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db_pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if pending.is_empty() {
|
||||||
|
tracing::info!("✅ No pending installer builds found during startup recovery");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
"🔁 Recovering {} pending installer build(s) after orchestrator startup",
|
||||||
|
pending.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for project in pending {
|
||||||
|
tracing::warn!(
|
||||||
|
"🔁 Recovering installer build for project {} ({}) deployment={:?} project_status={:?} deployment_status={:?}",
|
||||||
|
project.id,
|
||||||
|
project.name,
|
||||||
|
project.deployment_id,
|
||||||
|
project.installer_status,
|
||||||
|
project.deployment_status
|
||||||
|
);
|
||||||
|
|
||||||
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
|
project_id: project.id,
|
||||||
|
deployment_id: project.deployment_id,
|
||||||
|
name: project.name,
|
||||||
|
client: project.client,
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = installer_worker::handle_project_created(
|
||||||
|
state.db_pool.clone(),
|
||||||
|
state.mqtt_client.clone(),
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"❌ Startup recovery failed for installer build {}: {}",
|
||||||
|
project.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
@@ -24,9 +128,40 @@ 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())
|
.with(tracing_subscriber::fmt::layer().json().flatten_event(false))
|
||||||
.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
|
||||||
@@ -37,8 +172,36 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
loop {
|
loop {
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
match PgPoolOptions::new()
|
match PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(
|
||||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
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
|
||||||
{
|
{
|
||||||
@@ -68,8 +231,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
|
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
|
||||||
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
||||||
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
||||||
// 256KB: suficiente para binarios de instalador por partes y payloads comprimidos
|
// Control deploy requests are uncompressed ProjectConfig JSON from backend-api;
|
||||||
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
|
// large simulator imports can exceed the previous 256KB packet limit.
|
||||||
|
mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
|
||||||
|
|
||||||
if let (Ok(u), Ok(p)) = (env::var("MQTT_USER"), env::var("MQTT_PASSWORD")) {
|
if let (Ok(u), Ok(p)) = (env::var("MQTT_USER"), env::var("MQTT_PASSWORD")) {
|
||||||
mqtt_options.set_credentials(u, p);
|
mqtt_options.set_credentials(u, p);
|
||||||
@@ -77,13 +241,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
||||||
|
|
||||||
// Subscribe to Domain Events and Deployment Requests
|
// Subscribe to Domain Events and Deployment Requests. The event loop also
|
||||||
mqtt_client
|
// restores these subscriptions on every CONNACK because a broker reconnect
|
||||||
.subscribe("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce)
|
// can drop session subscriptions.
|
||||||
.await?;
|
subscribe_topics(&mqtt_client).await?;
|
||||||
mqtt_client
|
|
||||||
.subscribe("omnioil/control/deploy/#", rumqttc::QoS::AtLeastOnce)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Spawn MQTT event loop
|
// Spawn MQTT event loop
|
||||||
let state_worker = OrchestratorState {
|
let state_worker = OrchestratorState {
|
||||||
@@ -94,13 +255,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
Ok(item) => {
|
Ok(item) => match item {
|
||||||
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = item {
|
rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_)) => {
|
||||||
|
tracing::info!("🔌 MQTT connection acknowledged; restoring subscriptions");
|
||||||
|
if let Err(e) = subscribe_topics(&state_worker.mqtt_client).await {
|
||||||
|
tracing::error!("Failed to restore MQTT subscriptions: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) => {
|
||||||
let topic = publish.topic.clone();
|
let topic = publish.topic.clone();
|
||||||
let payload = publish.payload.clone();
|
let payload = publish.payload.clone();
|
||||||
let state = state_worker.clone();
|
let state = state_worker.clone();
|
||||||
|
|
||||||
if topic == "omnioil/events/project_created" {
|
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::<
|
||||||
@@ -116,10 +284,14 @@ 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: {}",
|
||||||
@@ -145,6 +317,9 @@ 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 {
|
||||||
@@ -154,7 +329,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
_ => {}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("MQTT event loop error: {}", e);
|
tracing::error!("MQTT event loop error: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
@@ -163,6 +339,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let recovery_state = OrchestratorState {
|
||||||
|
db_pool: pool.clone(),
|
||||||
|
mqtt_client: mqtt_client.clone(),
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = recover_pending_installer_builds(recovery_state).await {
|
||||||
|
tracing::error!("Startup installer recovery failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Subir binario genérico de Linux a MinIO si existe en el contenedor
|
// Subir binario genérico de Linux a MinIO si existe en el contenedor
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = installer_worker::upload_generic_linux_binary().await {
|
if let Err(e) = installer_worker::upload_generic_linux_binary().await {
|
||||||
|
|||||||
34
services/build-orchestrator/src/metrics.rs
Normal file
34
services/build-orchestrator/src/metrics.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin data-collector
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY . .
|
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
||||||
# Copiar caché del cacher
|
COPY --from=cacher /app /app
|
||||||
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,23 +23,25 @@ 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 FROM edge_assets WHERE code = $1")
|
let asset_opt = sqlx::query("SELECT id, project_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, asset_id, data)
|
INSERT INTO telemetry_raw (time, project_id, asset_id, data, source)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4, 'SCADA')
|
||||||
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,10 +51,11 @@ 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.22.0"
|
tray-icon = "0.24"
|
||||||
winit = "0.30.13"
|
winit = "0.30.13"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
windows-sys = { version = "0.59", features = [
|
windows-sys = { version = "0.61.2", features = [
|
||||||
"Win32_Security_Cryptography",
|
"Win32_Security_Cryptography",
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -78,9 +78,14 @@ 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
|
||||||
|
|
||||||
COPY . .
|
# Copiar el esqueleto dummy desde el cacher (contiene todos los Cargo.toml del workspace)
|
||||||
|
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) && \
|
||||||
@@ -123,7 +128,7 @@ COPY --from=builder /app/bin/linux/edge-agent /app/services/edge-agent/target/x8
|
|||||||
|
|
||||||
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
|
||||||
COPY ./services/edge-agent/wix /app/services/edge-agent/wix
|
COPY ./services/edge-agent/wix /app/services/edge-agent/wix
|
||||||
COPY ./services/edge-agent/icon.ico /app/services/edge-agent/
|
COPY ./services/edge-agent/*.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 && \
|
||||||
|
|||||||
BIN
services/edge-agent/icon.png
Normal file
BIN
services/edge-agent/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
BIN
services/edge-agent/icon_app.ico
Normal file
BIN
services/edge-agent/icon_app.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
services/edge-agent/icon_connected.ico
Normal file
BIN
services/edge-agent/icon_connected.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
services/edge-agent/icon_disconnected.ico
Normal file
BIN
services/edge-agent/icon_disconnected.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -6,32 +6,39 @@
|
|||||||
|
|
||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
#[cfg(windows)]
|
||||||
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};
|
|
||||||
|
|
||||||
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
#[cfg(windows)]
|
||||||
|
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};
|
||||||
|
|
||||||
struct EdgeTrayApp {
|
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
|
||||||
|
|
||||||
|
struct EdgeTrayApp {
|
||||||
tray_icon: Option<TrayIcon>,
|
tray_icon: Option<TrayIcon>,
|
||||||
quit_id: MenuId,
|
quit_id: MenuId,
|
||||||
test_id: MenuId,
|
test_id: MenuId,
|
||||||
status_label: MenuItem,
|
status_label: MenuItem,
|
||||||
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
|
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
|
||||||
status_rx: tokio::sync::mpsc::Receiver<String>,
|
status_rx: tokio::sync::mpsc::Receiver<IpcMessage>,
|
||||||
}
|
// Estados de animación y actualización
|
||||||
|
is_updating: bool,
|
||||||
|
animation_tick: usize,
|
||||||
|
last_tick_time: std::time::Instant,
|
||||||
|
connected_mqtt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl ApplicationHandler for EdgeTrayApp {
|
impl ApplicationHandler for EdgeTrayApp {
|
||||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
||||||
|
|
||||||
fn window_event(
|
fn window_event(
|
||||||
@@ -44,8 +51,96 @@ impl ApplicationHandler for EdgeTrayApp {
|
|||||||
|
|
||||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
// Recibir actualizaciones de estado del backend
|
// Recibir actualizaciones de estado del backend
|
||||||
while let Ok(new_status) = self.status_rx.try_recv() {
|
while let Ok(msg) = self.status_rx.try_recv() {
|
||||||
self.status_label.set_text(new_status);
|
match msg {
|
||||||
|
IpcMessage::StatusUpdate {
|
||||||
|
status,
|
||||||
|
connected_mqtt,
|
||||||
|
current_version,
|
||||||
|
} => {
|
||||||
|
self.connected_mqtt = connected_mqtt;
|
||||||
|
self.status_label
|
||||||
|
.set_text(format!("Estado: {} (v{})", status, current_version));
|
||||||
|
|
||||||
|
if !self.is_updating {
|
||||||
|
let icon_path =
|
||||||
|
std::env::current_exe().unwrap().parent().unwrap().join(
|
||||||
|
if connected_mqtt {
|
||||||
|
"icon_connected.ico"
|
||||||
|
} else {
|
||||||
|
"icon_disconnected.ico"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let icon = load_icon_or_default(&icon_path, connected_mqtt);
|
||||||
|
if let Some(ref mut tray) = self.tray_icon {
|
||||||
|
let _ = tray.set_icon(Some(icon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IpcMessage::OtaProgress {
|
||||||
|
percentage,
|
||||||
|
stage,
|
||||||
|
target_version,
|
||||||
|
error_message,
|
||||||
|
} => {
|
||||||
|
if stage == "Downloading" {
|
||||||
|
self.is_updating = true;
|
||||||
|
self.status_label.set_text(format!(
|
||||||
|
"Descargando v{} ({}%)",
|
||||||
|
target_version, percentage
|
||||||
|
));
|
||||||
|
} else if stage == "Verifying" {
|
||||||
|
self.is_updating = true;
|
||||||
|
self.status_label
|
||||||
|
.set_text(format!("Verificando v{}...", target_version));
|
||||||
|
} else if stage == "Applying" {
|
||||||
|
self.is_updating = true;
|
||||||
|
self.status_label
|
||||||
|
.set_text(format!("Instalando v{}...", target_version));
|
||||||
|
} else if stage == "Finished" {
|
||||||
|
self.is_updating = false;
|
||||||
|
self.status_label
|
||||||
|
.set_text(format!("¡v{} instalada con éxito!", target_version));
|
||||||
|
// Esperar la actualización de estado del agente para restaurar el icono
|
||||||
|
} else if stage == "Error" {
|
||||||
|
self.is_updating = false;
|
||||||
|
let err =
|
||||||
|
error_message.unwrap_or_else(|| "Error desconocido".to_string());
|
||||||
|
self.status_label
|
||||||
|
.set_text(format!("Fallo actualización: {}", err));
|
||||||
|
|
||||||
|
// Restaurar icono original
|
||||||
|
let icon_path =
|
||||||
|
std::env::current_exe().unwrap().parent().unwrap().join(
|
||||||
|
if self.connected_mqtt {
|
||||||
|
"icon_connected.ico"
|
||||||
|
} else {
|
||||||
|
"icon_disconnected.ico"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let icon = load_icon_or_default(&icon_path, self.connected_mqtt);
|
||||||
|
if let Some(ref mut tray) = self.tray_icon {
|
||||||
|
let _ = tray.set_icon(Some(icon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IpcMessage::Notification { title, message } => {
|
||||||
|
println!("[NOTIFICATION] {}: {}", title, message);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realizar animación si la actualización está activa
|
||||||
|
if self.is_updating {
|
||||||
|
if self.last_tick_time.elapsed() >= std::time::Duration::from_millis(150) {
|
||||||
|
self.animation_tick += 1;
|
||||||
|
let animated_icon = generate_updating_icon(self.animation_tick);
|
||||||
|
if let Some(ref mut tray) = self.tray_icon {
|
||||||
|
let _ = tray.set_icon(Some(animated_icon));
|
||||||
|
}
|
||||||
|
self.last_tick_time = std::time::Instant::now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revisar clicks en el menú
|
// Revisar clicks en el menú
|
||||||
@@ -60,10 +155,9 @@ impl ApplicationHandler for EdgeTrayApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
pub fn run() {
|
||||||
async fn main() {
|
|
||||||
let event_loop = EventLoop::new().unwrap();
|
let event_loop = EventLoop::new().unwrap();
|
||||||
event_loop.set_control_flow(ControlFlow::Wait);
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
|
||||||
@@ -90,24 +184,22 @@ async fn main() {
|
|||||||
.parent()
|
.parent()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.join("icon.ico");
|
.join("icon.ico");
|
||||||
let icon = load_icon(&icon_path);
|
let icon = load_icon_or_default(&icon_path, false);
|
||||||
|
|
||||||
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 v2")
|
.with_tooltip("OmniOil Edge Agent")
|
||||||
.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::<String>(10);
|
let (status_tx, status_rx) = tokio::sync::mpsc::channel::<IpcMessage>(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) => {
|
||||||
@@ -115,7 +207,13 @@ async fn main() {
|
|||||||
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.send("Estado: Agente Conectado".to_string()).await;
|
let _ = status_tx
|
||||||
|
.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! {
|
||||||
@@ -124,34 +222,30 @@ async fn main() {
|
|||||||
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) {
|
||||||
match msg {
|
let _ = status_tx.send(msg).await;
|
||||||
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() => {
|
||||||
let json = serde_json::to_string(&IpcMessage::Command(cmd)).unwrap() + "\n";
|
if let Ok(payload) = serde_json::to_string(&IpcMessage::Command(cmd)) {
|
||||||
let _ = write_half.write_all(json.as_bytes()).await;
|
let _ = write_half.write_all(format!("{}\n", payload).as_bytes()).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = status_tx
|
let _ = status_tx
|
||||||
.send("Estado: Agente no detectado".to_string())
|
.send(IpcMessage::StatusUpdate {
|
||||||
|
status: "Agente no detectado".to_string(),
|
||||||
|
connected_mqtt: false,
|
||||||
|
current_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -163,19 +257,110 @@ async fn main() {
|
|||||||
status_label,
|
status_label,
|
||||||
command_tx,
|
command_tx,
|
||||||
status_rx,
|
status_rx,
|
||||||
|
is_updating: false,
|
||||||
|
animation_tick: 0,
|
||||||
|
last_tick_time: std::time::Instant::now(),
|
||||||
|
connected_mqtt: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
event_loop.run_app(&mut app).unwrap();
|
event_loop.run_app(&mut app).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_icon(path: &std::path::Path) -> tray_icon::Icon {
|
fn load_icon_or_default(path: &std::path::Path, connected: bool) -> tray_icon::Icon {
|
||||||
let (icon_rgba, icon_width, icon_height) = {
|
if path.exists() {
|
||||||
let image = image::open(path)
|
if let Ok(image) = image::open(path) {
|
||||||
.expect("Failed to open icon path")
|
let image = image.into_rgba8();
|
||||||
.into_rgba8();
|
|
||||||
let (width, height) = image.dimensions();
|
let (width, height) = image.dimensions();
|
||||||
let rgba = image.into_raw();
|
let rgba = image.into_raw();
|
||||||
(rgba, width, height)
|
if let Ok(icon) = tray_icon::Icon::from_rgba(rgba, width, height) {
|
||||||
};
|
return icon;
|
||||||
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar icono de estado elegante de 16x16 en memoria
|
||||||
|
let width = 16;
|
||||||
|
let height = 16;
|
||||||
|
let mut rgba = vec![0u8; (width * height * 4) as usize];
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let idx = ((y * width + x) * 4) as usize;
|
||||||
|
|
||||||
|
// Punto de estado en la parte inferior derecha: x: 11..14, y: 11..14
|
||||||
|
let is_dot = x >= 11 && x <= 14 && y >= 11 && y <= 14;
|
||||||
|
if is_dot {
|
||||||
|
if connected {
|
||||||
|
// Punto Verde (Conectado)
|
||||||
|
rgba[idx] = 46;
|
||||||
|
rgba[idx + 1] = 204;
|
||||||
|
rgba[idx + 2] = 113;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
} else {
|
||||||
|
// Punto Rojo (Desconectado)
|
||||||
|
rgba[idx] = 231;
|
||||||
|
rgba[idx + 1] = 76;
|
||||||
|
rgba[idx + 2] = 60;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fondo gris slate de tema oscuro moderno
|
||||||
|
rgba[idx] = 44;
|
||||||
|
rgba[idx + 1] = 62;
|
||||||
|
rgba[idx + 2] = 80;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tray_icon::Icon::from_rgba(rgba, width, height).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_updating_icon(tick: usize) -> tray_icon::Icon {
|
||||||
|
let width = 16;
|
||||||
|
let height = 16;
|
||||||
|
let mut rgba = vec![0u8; (width * height * 4) as usize];
|
||||||
|
|
||||||
|
let num_dots = 8;
|
||||||
|
let dot_idx = tick % num_dots;
|
||||||
|
|
||||||
|
// Círculo de radio 5.0 centrado en 7.5, 7.5
|
||||||
|
let angle = (dot_idx as f64) * (2.0 * std::f64::consts::PI / (num_dots as f64));
|
||||||
|
let active_x = (7.5 + 5.0 * angle.cos()) as i32;
|
||||||
|
let active_y = (7.5 + 5.0 * angle.sin()) as i32;
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let idx = ((y * width + x) * 4) as usize;
|
||||||
|
|
||||||
|
let dx = x as i32 - active_x;
|
||||||
|
let dy = y as i32 - active_y;
|
||||||
|
let dist_sq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if dist_sq <= 1 {
|
||||||
|
// Punto naranja brillante de animación
|
||||||
|
rgba[idx] = 255;
|
||||||
|
rgba[idx + 1] = 165;
|
||||||
|
rgba[idx + 2] = 0;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
} else {
|
||||||
|
// Fondo azul oscuro moderno
|
||||||
|
rgba[idx] = 0;
|
||||||
|
rgba[idx + 1] = 50;
|
||||||
|
rgba[idx + 2] = 100;
|
||||||
|
rgba[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tray_icon::Icon::from_rgba(rgba, width, height).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
app::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn main() {
|
||||||
|
println!("System tray companion is only supported on Windows.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,8 +297,9 @@ impl AgentConfig {
|
|||||||
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
|
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
|
||||||
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
|
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
|
||||||
mqtt_opts.set_clean_session(true);
|
mqtt_opts.set_clean_session(true);
|
||||||
// 256KB: suficiente para la respuesta de provisioning cifrada + comprimida
|
// 2MB: keep provisioning/deploy paths aligned with the broker and
|
||||||
mqtt_opts.set_max_packet_size(256 * 1024, 256 * 1024);
|
// internal services for large local simulator configurations.
|
||||||
|
mqtt_opts.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
|
||||||
|
|
||||||
// Aplicar el transporte correcto según el stub.
|
// Aplicar el transporte correcto según el stub.
|
||||||
if stub.use_ws {
|
if stub.use_ws {
|
||||||
|
|||||||
@@ -4,16 +4,26 @@
|
|||||||
//! 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: archivo plano con permisos chmod 600, directorio chmod 700.
|
//! - Linux: AES-256-GCM con clave derivada vía PBKDF2-HMAC-SHA256 (600K iteraciones).
|
||||||
//! La seguridad viene de los permisos del sistema de archivos. El servicio
|
//! Cada archivo usa un salt aleatorio único de 16 bytes, almacenado junto al ciphertext.
|
||||||
//! corre como usuario dedicado `omnioil-agent` que es el único con acceso.
|
//! Formato en disco: [salt 16B][nonce 12B][ciphertext].
|
||||||
|
//!
|
||||||
|
//! - 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: escribe en texto plano con chmod 600.
|
/// Linux: cifra con AES-256-GCM + PBKDF2 (600K iteraciones, salt aleatorio).
|
||||||
|
/// 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)]
|
||||||
{
|
{
|
||||||
@@ -21,7 +31,38 @@ 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(not(windows))]
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
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))?;
|
||||||
@@ -38,7 +79,33 @@ 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(not(windows))]
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -71,12 +138,15 @@ 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::{
|
||||||
CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
|
CRYPT_INTEGER_BLOB, 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.
|
||||||
@@ -84,31 +154,34 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
struct DataBlob {
|
// Usar catch_unwind para capturar cualquier abort nativo y convertirlo
|
||||||
cb_data: u32,
|
// en un error manejable en lugar de matar el proceso completo.
|
||||||
pb_data: *mut u8,
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
protect_inner(data)
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|_| Err(anyhow!("CryptProtectData causó un panic/abort — los datos de entrada pueden estar corruptos")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
|
fn protect_inner(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
let input = DataBlob {
|
let mut input = CRYPT_INTEGER_BLOB {
|
||||||
cb_data: data.len() as u32,
|
cbData: data.len() as u32,
|
||||||
pb_data: data.as_ptr() as *mut u8,
|
pbData: data.as_ptr() as *mut u8,
|
||||||
};
|
};
|
||||||
let mut output = DataBlob {
|
let mut output = CRYPT_INTEGER_BLOB {
|
||||||
cb_data: 0,
|
cbData: 0,
|
||||||
pb_data: std::ptr::null_mut(),
|
pbData: std::ptr::null_mut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
CryptProtectData(
|
CryptProtectData(
|
||||||
&input as *const DataBlob as *const _,
|
&mut input as *mut _,
|
||||||
std::ptr::null(),
|
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
std::ptr::null(),
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
CRYPTPROTECT_LOCAL_MACHINE,
|
CRYPTPROTECT_LOCAL_MACHINE,
|
||||||
&mut output as *mut DataBlob as *mut _,
|
&mut output as *mut _,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,30 +192,37 @@ mod dpapi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() };
|
unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
|
||||||
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
unsafe { LocalFree(output.pbData 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>> {
|
||||||
let input = DataBlob {
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
cb_data: data.len() as u32,
|
unprotect_inner(data)
|
||||||
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 = DataBlob {
|
let mut output = CRYPT_INTEGER_BLOB {
|
||||||
cb_data: 0,
|
cbData: 0,
|
||||||
pb_data: std::ptr::null_mut(),
|
pbData: std::ptr::null_mut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
CryptUnprotectData(
|
CryptUnprotectData(
|
||||||
&input as *const DataBlob as *const _,
|
&mut input as *mut _,
|
||||||
|
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 DataBlob as *mut _,
|
&mut output as *mut _,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,8 +233,8 @@ mod dpapi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() };
|
unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
|
||||||
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) };
|
unsafe { LocalFree(output.pbData as *mut std::ffi::c_void) };
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,24 @@ 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<()>>>>,
|
handles: Arc<Mutex<Vec<(tokio::task::JoinHandle<()>, DeviceTaskSpec)>>>,
|
||||||
deadband_threshold_pct: f64,
|
deadband_threshold_pct: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +50,47 @@ 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>,
|
||||||
@@ -50,7 +104,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 {
|
||||||
@@ -61,13 +115,38 @@ 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 {
|
||||||
@@ -75,14 +154,55 @@ 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 handle = tokio::spawn(async move {
|
let spec = DeviceTaskSpec {
|
||||||
|
device: device.clone(),
|
||||||
|
variables,
|
||||||
|
mqtt: self.mqtt_client.clone(),
|
||||||
|
topic: telemetry_topic.clone(),
|
||||||
|
storage: self.storage.clone(),
|
||||||
|
project_id: self.project_id,
|
||||||
|
deadband_pct: self.deadband_threshold_pct,
|
||||||
|
reporter: reporter.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = tokio::spawn(run_device_collector(spec.clone()));
|
||||||
|
handles.push((handle, spec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"Data collector operational with {} active drivers",
|
||||||
|
handles.len()
|
||||||
|
);
|
||||||
|
tracing::info!("✅ {}", msg);
|
||||||
|
let _ = log_reporter.info("DEPLOY", &msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loop principal de recolección para un dispositivo individual.
|
||||||
|
/// Extraído como función separada para permitir re-lanzamiento por el supervisor.
|
||||||
|
///
|
||||||
|
/// Incluye **backoff exponencial** en reconexiones:
|
||||||
|
/// 5s → 10s → 20s → 40s → 60s → 5min (máx).
|
||||||
|
/// El backoff se resetea al reconectar exitosamente.
|
||||||
|
async fn run_device_collector(spec: DeviceTaskSpec) {
|
||||||
|
let DeviceTaskSpec {
|
||||||
|
device,
|
||||||
|
variables,
|
||||||
|
mqtt,
|
||||||
|
topic,
|
||||||
|
storage,
|
||||||
|
project_id,
|
||||||
|
deadband_pct,
|
||||||
|
reporter,
|
||||||
|
} = spec;
|
||||||
|
|
||||||
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
||||||
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
||||||
let unit_id = device
|
let unit_id = device
|
||||||
@@ -174,13 +294,14 @@ impl DataCollectorManager {
|
|||||||
);
|
);
|
||||||
Box::new(SqlDbDriver::new(conn_str))
|
Box::new(SqlDbDriver::new(conn_str))
|
||||||
}
|
}
|
||||||
_ => {
|
other => {
|
||||||
let msg = format!(
|
// Este branch no debería alcanzarse porque filtramos arriba,
|
||||||
"Unsupported protocol for device {}: {:?}",
|
// pero lo dejamos como safety net.
|
||||||
device.name, device.protocol
|
tracing::error!(
|
||||||
|
"❌ Protocol {:?} reached driver init for device {} — this is a bug.",
|
||||||
|
other,
|
||||||
|
device.name
|
||||||
);
|
);
|
||||||
tracing::error!("{}", msg);
|
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -189,24 +310,58 @@ impl DataCollectorManager {
|
|||||||
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
||||||
let mut connected_notified = false;
|
let mut connected_notified = false;
|
||||||
|
|
||||||
|
// ─── Backoff exponencial para reconexiones ──────────────────────────
|
||||||
|
// Evita log spam y tráfico MQTT innecesario cuando un dispositivo
|
||||||
|
// está apagado permanentemente.
|
||||||
|
let mut reconnect_delay = Duration::from_secs(5);
|
||||||
|
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); // 5 min max
|
||||||
|
let mut consecutive_connect_failures: u32 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
timer.tick().await;
|
timer.tick().await;
|
||||||
|
|
||||||
if !driver.is_alive() {
|
if !driver.is_alive() {
|
||||||
connected_notified = false;
|
connected_notified = false;
|
||||||
|
|
||||||
|
// Aplicar backoff: esperar antes de reintentar si ya fallamos antes
|
||||||
|
if consecutive_connect_failures > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
"⏳ Backoff: waiting {:?} before reconnecting to {}",
|
||||||
|
reconnect_delay,
|
||||||
|
device.name
|
||||||
|
);
|
||||||
|
tokio::time::sleep(reconnect_delay).await;
|
||||||
|
}
|
||||||
|
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"🔌 Attempting to connect to {} at {}:{}...",
|
"🔌 Attempting to connect to {} at {}:{}...",
|
||||||
device.name, device.host, device.port
|
device.name, device.host, device.port
|
||||||
);
|
);
|
||||||
|
// Solo reportar al servidor cada 5 intentos para no saturar logs
|
||||||
|
if consecutive_connect_failures % 5 == 0 {
|
||||||
let _ = reporter.info("COLLECTOR", &msg).await;
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = driver.connect().await {
|
if let Err(e) = driver.connect().await {
|
||||||
let msg =
|
consecutive_connect_failures += 1;
|
||||||
format!("❌ Connection failed for {}: {}", device.name, e);
|
let msg = format!(
|
||||||
|
"❌ Connection failed for {} (attempt #{}): {}",
|
||||||
|
device.name, consecutive_connect_failures, e
|
||||||
|
);
|
||||||
tracing::warn!("{}", msg);
|
tracing::warn!("{}", msg);
|
||||||
|
// Solo reportar al servidor en los primeros 3 intentos y luego cada 10
|
||||||
|
if consecutive_connect_failures <= 3 || consecutive_connect_failures % 10 == 0 {
|
||||||
let _ = reporter.error("COLLECTOR", &msg).await;
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar backoff: 5s → 10s → 20s → 40s → 60s → 300s
|
||||||
|
reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conexión exitosa: resetear backoff
|
||||||
|
consecutive_connect_failures = 0;
|
||||||
|
reconnect_delay = Duration::from_secs(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !connected_notified {
|
if !connected_notified {
|
||||||
@@ -275,7 +430,7 @@ impl DataCollectorManager {
|
|||||||
device.name, e
|
device.name, e
|
||||||
);
|
);
|
||||||
let _ = reporter.error("MQTT", &msg).await;
|
let _ = reporter.error("MQTT", &msg).await;
|
||||||
let _ = storage_clone.save(&topic, &payload_str);
|
let _ = storage.save(&topic, &payload_str).await;
|
||||||
} else {
|
} else {
|
||||||
let _ = reporter
|
let _ = reporter
|
||||||
.info(
|
.info(
|
||||||
@@ -307,16 +462,4 @@ impl DataCollectorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
handles.push(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = format!(
|
|
||||||
"Data collector operational with {} active drivers",
|
|
||||||
handles.len()
|
|
||||||
);
|
|
||||||
tracing::info!("✅ {}", msg);
|
|
||||||
let _ = log_reporter.info("DEPLOY", &msg).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ use axum::{
|
|||||||
routing::post,
|
routing::post,
|
||||||
};
|
};
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
use tokio::time::{Duration, interval};
|
use tokio::time::{Duration, interval};
|
||||||
@@ -14,6 +15,7 @@ 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>,
|
||||||
@@ -30,6 +32,7 @@ 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(),
|
||||||
@@ -70,16 +73,13 @@ async fn ingest_data(
|
|||||||
) -> Json<Value> {
|
) -> Json<Value> {
|
||||||
let topic = &state.telemetry_topic;
|
let topic = &state.telemetry_topic;
|
||||||
|
|
||||||
// Asegurar que el payload tenga un timestamp de captura (Capture Time)
|
if let Err(e) = normalize_telemetry_payload(&mut payload, state.project_id) {
|
||||||
// Si Node-RED no lo envió, lo generamos en el momento justo de recibirlo en el borde.
|
tracing::warn!(
|
||||||
if payload.get("timestamp").is_none() {
|
project_id = %state.project_id,
|
||||||
if let Some(obj) = payload.as_object_mut() {
|
error = %e,
|
||||||
obj.insert(
|
"Data Gateway rejected invalid SCADA telemetry payload"
|
||||||
"timestamp".to_string(),
|
|
||||||
json!(chrono::Utc::now().to_rfc3339()),
|
|
||||||
);
|
);
|
||||||
obj.insert("is_agent_timestamped".to_string(), json!(true));
|
return Json(json!({ "status": "error", "message": e }));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if let Err(err) = state.storage.save(topic, &data_str).await {
|
||||||
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) {
|
if let Err(err) = state.storage.save(topic, &data_str).await {
|
||||||
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,6 +142,66 @@ 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
|
||||||
@@ -149,3 +209,105 @@ 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,39 +3,84 @@
|
|||||||
//! 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 {
|
||||||
last_values: HashMap<String, f64>,
|
entries: HashMap<String, DeadbandEntry>,
|
||||||
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 {
|
||||||
last_values: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
threshold_pct,
|
threshold_pct,
|
||||||
|
max_silence_secs: 300, // 5 minutos — ANH 0651 Art. 17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the value should be published (first reading or change exceeds threshold).
|
/// Returns `true` if the value should be published:
|
||||||
|
/// - 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.last_values.get(key).copied() {
|
match self.entries.get(key) {
|
||||||
None => {
|
None => {
|
||||||
self.last_values.insert(key.to_string(), new_val);
|
self.entries.insert(
|
||||||
|
key.to_string(),
|
||||||
|
DeadbandEntry {
|
||||||
|
last_value: new_val,
|
||||||
|
last_published_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Some(last) => {
|
Some(entry) => {
|
||||||
let change_pct = if last == 0.0 {
|
let change_pct = if entry.last_value == 0.0 {
|
||||||
if new_val == 0.0 { 0.0 } else { 100.0 }
|
if new_val == 0.0 {
|
||||||
|
0.0
|
||||||
} else {
|
} else {
|
||||||
((new_val - last) / last.abs() * 100.0).abs()
|
100.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
((new_val - entry.last_value) / entry.last_value.abs() * 100.0).abs()
|
||||||
};
|
};
|
||||||
|
|
||||||
if change_pct > self.threshold_pct {
|
let time_elapsed = entry.last_published_at.elapsed().as_secs();
|
||||||
self.last_values.insert(key.to_string(), new_val);
|
let force_by_time = time_elapsed >= self.max_silence_secs;
|
||||||
|
|
||||||
|
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
|
||||||
@@ -77,4 +122,16 @@ 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,6 +13,8 @@ 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 {
|
||||||
@@ -22,6 +24,7 @@ impl ModbusTcpDriver {
|
|||||||
port,
|
port,
|
||||||
unit_id,
|
unit_id,
|
||||||
ctx: None,
|
ctx: None,
|
||||||
|
consecutive_transport_errors: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,8 +33,23 @@ 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,
|
||||||
@@ -42,9 +60,13 @@ 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>,
|
||||||
@@ -55,6 +77,7 @@ 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(':') {
|
||||||
@@ -65,33 +88,66 @@ impl ProtocolDriver for ModbusTcpDriver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let addr: u16 = addr_str.parse().unwrap_or(0);
|
let addr: u16 = addr_str.parse().unwrap_or(0);
|
||||||
match area {
|
|
||||||
"HR" => match ctx.read_holding_registers(addr, 1).await {
|
// Timeout de 5s por lectura individual — evita que un PLC inactivo congele la tarea
|
||||||
Ok(Ok(registers)) => {
|
let read_result = match area {
|
||||||
|
"HR" => {
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
ctx.read_holding_registers(addr, 1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"IR" => {
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
ctx.read_input_registers(addr, 1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("Unsupported Modbus area: {}", area);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match read_result {
|
||||||
|
Ok(Ok(Ok(registers))) => {
|
||||||
if let Some(v) = registers.first() {
|
if let Some(v) = registers.first() {
|
||||||
results.insert(alias.clone(), json!(v));
|
results.insert(alias.clone(), json!(v));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(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!(
|
||||||
|
"Modbus {} read timeout (5s) for addr {} — PLC may be unresponsive",
|
||||||
|
area,
|
||||||
|
addr
|
||||||
|
);
|
||||||
|
had_transport_error = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
|
|
||||||
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
|
|
||||||
},
|
|
||||||
_ => tracing::warn!("Unsupported Modbus area: {}", area),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if had_transport_error {
|
||||||
|
self.consecutive_transport_errors += 1;
|
||||||
|
} else {
|
||||||
|
self.consecutive_transport_errors = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifica si la conexión está activa Y funcional.
|
||||||
|
/// Si ha habido 3+ errores de transporte consecutivos, la conexión se
|
||||||
|
/// considera muerta y se fuerza reconexión.
|
||||||
fn is_alive(&self) -> bool {
|
fn is_alive(&self) -> bool {
|
||||||
self.ctx.is_some()
|
self.ctx.is_some() && self.consecutive_transport_errors < 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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 {
|
||||||
@@ -22,38 +24,80 @@ 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 addr: IpAddr = self.host.parse()?;
|
let host = self.host.clone();
|
||||||
let options = tcp::Options::new(addr, self.rack, self.slot, Connection::PG);
|
let rack = self.rack;
|
||||||
let conn = tcp::Transport::connect(options)?;
|
let slot = self.slot;
|
||||||
let client = Client::new(conn)?;
|
|
||||||
|
// spawn_blocking mueve la operación TCP síncrona a un hilo dedicado.
|
||||||
|
// El timeout evita que un PLC inalcanzable congele el hilo indefinidamente.
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let addr: IpAddr = host.parse()?;
|
||||||
|
let options = tcp::Options::new(addr, rack, slot, Connection::PG);
|
||||||
|
let conn = tcp::Transport::connect(options)
|
||||||
|
.map_err(|e| anyhow::anyhow!("S7 TCP connect: {}", e))?;
|
||||||
|
Client::new(conn).map_err(|e| anyhow::anyhow!("S7 client init: {}", e))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Ok(client))) => {
|
||||||
self.client = Some(client);
|
self.client = Some(client);
|
||||||
|
self.consecutive_errors = 0;
|
||||||
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Ok(Ok(Err(e))) => Err(e),
|
||||||
|
Ok(Err(e)) => Err(anyhow::anyhow!("S7 spawn_blocking join error: {}", e)),
|
||||||
|
Err(_) => Err(anyhow::anyhow!(
|
||||||
|
"S7 connection timeout (10s) for {}",
|
||||||
|
self.host
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
self.client = None;
|
self.client = None;
|
||||||
|
self.consecutive_errors = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lee variables del PLC usando `spawn_blocking` + timeout.
|
||||||
|
/// `ag_read` es síncrono y puede bloquearse indefinidamente si el PLC
|
||||||
|
/// acepta la conexión TCP pero no responde a las queries.
|
||||||
async fn read_variables(
|
async fn read_variables(
|
||||||
&mut self,
|
&mut self,
|
||||||
variables: &HashMap<String, String>,
|
variables: &HashMap<String, String>,
|
||||||
) -> anyhow::Result<HashMap<String, Value>> {
|
) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
let client = self
|
let client = self
|
||||||
.client
|
.client
|
||||||
.as_mut()
|
.take()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
let mut results = HashMap::new();
|
|
||||||
|
|
||||||
for (alias, address) in variables {
|
let vars_clone: Vec<(String, String)> = variables
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let read_result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
let mut client = client;
|
||||||
|
|
||||||
|
for (alias, address) in &vars_clone {
|
||||||
if address.starts_with("DB") {
|
if address.starts_with("DB") {
|
||||||
let parts: Vec<&str> = address[2..].split('.').collect();
|
let parts: Vec<&str> = address[2..].split('.').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
@@ -70,10 +114,34 @@ impl ProtocolDriver for S7CommDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(client, results)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match read_result {
|
||||||
|
Ok(Ok((client, results))) => {
|
||||||
|
self.client = Some(client);
|
||||||
|
self.consecutive_errors = 0;
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
self.consecutive_errors += 1;
|
||||||
|
Err(anyhow::anyhow!("S7 read spawn_blocking error: {}", e))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.consecutive_errors += 1;
|
||||||
|
// No restauramos self.client — la conexión probablemente está muerta
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"S7 read timeout (10s) — PLC may be unresponsive"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica si el driver tiene una conexión activa Y no ha acumulado errores.
|
||||||
|
/// 3 errores consecutivos = conexión considerada muerta → fuerza reconexión.
|
||||||
fn is_alive(&self) -> bool {
|
fn is_alive(&self) -> bool {
|
||||||
self.client.is_some()
|
self.client.is_some() && self.consecutive_errors < 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 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};
|
||||||
@@ -29,6 +30,10 @@ 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
|
||||||
@@ -39,6 +44,41 @@ 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,
|
||||||
@@ -49,8 +89,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: None,
|
cpu_usage_percent: cpu_usage,
|
||||||
memory_usage_mb: None,
|
memory_usage_mb: memory_mb.map(|m| m as f64),
|
||||||
restart_count: 0,
|
restart_count: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
@@ -66,9 +106,130 @@ 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 via MQTT for project: {}",
|
"📡 Native health report sent (CPU: {:?}%, RAM: {:?}MB, uptime: {}s)",
|
||||||
project_id
|
cpu_usage,
|
||||||
|
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,11 +6,18 @@ 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,17 +16,41 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
|
|||||||
loop {
|
loop {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let server = ServerOptions::new()
|
// create_pipe_server es una fn sincrónica: todos los tipos no-Send de
|
||||||
.first_pipe_instance(true)
|
// Windows (SECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, raw pointers) se
|
||||||
.create(pipe_name)?;
|
// dropean antes de cualquier .await, satisfaciendo el bound Send de
|
||||||
|
// 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
|
// Esperar cliente con timeout para no bloquear indefinidamente
|
||||||
server.connect().await?;
|
// si ningún tray icon se conecta.
|
||||||
|
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::error!("IPC client error: {}", e);
|
tracing::warn!("⚠️ IPC client disconnected: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,6 +64,61 @@ 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,
|
||||||
@@ -55,7 +134,10 @@ 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";
|
||||||
write_half.write_all(json.as_bytes()).await?;
|
if let Err(e) = write_half.write_all(json.as_bytes()).await {
|
||||||
|
tracing::debug!("IPC write error (client likely disconnected): {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => break,
|
_ => break,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ 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 {
|
||||||
let _ = mqtt
|
if let Err(e) = 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,6 +25,7 @@ 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};
|
||||||
@@ -64,6 +65,7 @@ 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>() {
|
||||||
@@ -77,7 +79,53 @@ 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());
|
||||||
tracing::error!("🚨 PÁNICO (CRASH) detectado en {}: {}", location, message);
|
let crash_msg = format!("🚨 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));
|
||||||
}));
|
}));
|
||||||
@@ -201,8 +249,18 @@ pub async fn run_agent(
|
|||||||
mqtt_client.clone(),
|
mqtt_client.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Canal para comandos
|
// Canal para comandos — capacidad 64 para absorber ráfagas de deploy signals
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16);
|
// y comandos OTA sin bloquear al mqtt_listener.
|
||||||
|
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(
|
||||||
@@ -258,13 +316,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);
|
let _ = queue_storage.cleanup_old_queue_entries(7).await;
|
||||||
|
|
||||||
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);
|
let _ = queue_storage.cleanup_old_queue_entries(7).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +334,62 @@ 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();
|
||||||
@@ -284,6 +398,34 @@ 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(
|
||||||
@@ -293,7 +435,21 @@ 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 {
|
||||||
@@ -313,6 +469,7 @@ 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 => {
|
||||||
@@ -331,6 +488,7 @@ 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() {
|
||||||
@@ -344,6 +502,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -398,6 +557,10 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,8 +139,9 @@ pub async fn setup_mqtt_client(
|
|||||||
|
|
||||||
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
||||||
mqtt_options.set_clean_session(true);
|
mqtt_options.set_clean_session(true);
|
||||||
// 256KB: suficiente para cualquier mensaje de telemetría comprimida
|
// 2MB: supports large deploy signals and high-cardinality telemetry payloads
|
||||||
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
|
// during local simulator runs with hundreds of variables per device.
|
||||||
|
mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
|
||||||
|
|
||||||
// Last Will Testament: si el agente se desconecta inesperadamente,
|
// Last Will Testament: si el agente se desconecta inesperadamente,
|
||||||
// el broker publica automáticamente el estado "offline" de forma inmediata.
|
// el broker publica automáticamente el estado "offline" de forma inmediata.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
//! 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,
|
||||||
@@ -13,9 +18,8 @@ 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::sync::Mutex;
|
use std::path::Path;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
const SALT: &[u8; 15] = b"omnioil_db_salt";
|
|
||||||
|
|
||||||
pub struct StoreAndForward {
|
pub struct StoreAndForward {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
@@ -25,29 +29,48 @@ 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
|
||||||
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
let db_path = Path::new(db_path);
|
||||||
|
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
|
// 2. Preparar Cifrado con salt aleatorio persistido
|
||||||
// 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()))
|
||||||
.unwrap_or_else(|| "fallback_unsafe_secret_change_me".to_string());
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"OMNIOIL_MASTER_SECRET no está configurado. \
|
||||||
|
El edge-agent no puede iniciar sin este secreto maestro."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if master_secret == "fallback_unsafe_secret_change_me" {
|
// Salt aleatorio por dispositivo — persistir junto al DB
|
||||||
tracing::warn!(
|
let salt_path = db_path.with_extension("salt");
|
||||||
"⚠️ Usando llave de cifrado por defecto. La base de datos local no es segura."
|
let salt: [u8; 16] = if salt_path.exists() {
|
||||||
);
|
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, 1000, &mut key);
|
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), &salt, 600_000, &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))?;
|
||||||
|
|
||||||
@@ -59,7 +82,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 = 5000;",
|
PRAGMA busy_timeout = 30000;",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -80,7 +103,8 @@ impl StoreAndForward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Guarda un payload ENCRIPTADO usando la conexión persistente.
|
/// Guarda un payload ENCRIPTADO usando la conexión persistente.
|
||||||
pub fn save(&self, topic: &str, payload: &str) -> Result<()> {
|
/// Ahora es async para usar tokio::sync::Mutex de forma segura.
|
||||||
|
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);
|
||||||
@@ -90,10 +114,7 @@ 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
|
let conn = self.conn.lock().await;
|
||||||
.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()],
|
||||||
@@ -101,13 +122,15 @@ impl StoreAndForward {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Desencripta y envía los datos usando la conexión persistente.
|
/// Desencripta y envía los datos pendientes vía MQTT.
|
||||||
|
///
|
||||||
|
/// ⚠️ 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
|
let conn = self.conn.lock().await;
|
||||||
.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",
|
||||||
)?;
|
)?;
|
||||||
@@ -119,8 +142,10 @@ 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();
|
||||||
|
|
||||||
@@ -145,12 +170,9 @@ impl StoreAndForward {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch delete all sent messages in a single transaction
|
// Fase 3: Eliminar mensajes enviados (lock corto de nuevo)
|
||||||
if !ids_to_delete.is_empty() {
|
if !ids_to_delete.is_empty() {
|
||||||
let conn = self
|
let conn = self.conn.lock().await;
|
||||||
.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 ({})",
|
||||||
@@ -172,12 +194,9 @@ 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 fn cleanup_old_queue_entries(&self, max_days: i64) -> Result<usize> {
|
pub async 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
|
let conn = self.conn.lock().await;
|
||||||
.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,6 +87,11 @@ 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);
|
||||||
@@ -129,15 +134,50 @@ 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 fallo
|
// Configurar el servicio para reiniciarse automáticamente tras CUALQUIER fallo.
|
||||||
// Reiniciar tras 5s (primer fallo), 15s (segundo), 30s (posteriores)
|
// reset= 86400 → reinicia el contador de fallos después de 24h sin fallos.
|
||||||
let _ = std::process::Command::new("sc")
|
// actions= → restart/5s (1er fallo), restart/15s (2do), restart/30s (posterior).
|
||||||
|
let recovery_result = std::process::Command::new("sc")
|
||||||
.arg("failure")
|
.arg("failure")
|
||||||
.arg(SERVICE_NAME)
|
.arg(SERVICE_NAME)
|
||||||
.arg("reset= 0")
|
.arg("reset= 86400")
|
||||||
.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,6 +29,7 @@ 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.
|
||||||
///
|
///
|
||||||
@@ -38,6 +39,7 @@ use tracing::{info, warn};
|
|||||||
/// - `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,
|
||||||
@@ -45,6 +47,7 @@ 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)
|
||||||
@@ -72,20 +75,99 @@ 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 = download_asset(&client, download_url).await?;
|
let content = match download_asset(&client, download_url, target_version, ipc_tx.as_ref()).await {
|
||||||
verify_sha256(&content, sha256, verify_signature)?;
|
Ok(c) => c,
|
||||||
|
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";
|
||||||
if use_msi {
|
let apply_res = 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +203,13 @@ fn verify_sha256(content: &[u8], expected: Option<&str>, verify: bool) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Descarga asset desde la URL pre-firmada de MinIO.
|
/// Descarga asset desde la URL pre-firmada de MinIO en chunks para reportar progreso.
|
||||||
async fn download_asset(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
async fn download_asset(
|
||||||
|
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)
|
||||||
@@ -138,11 +225,39 @@ async fn download_asset(client: &reqwest::Client, url: &str) -> Result<Vec<u8>>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(response
|
let total_size = response.content_length().unwrap_or(0);
|
||||||
.bytes()
|
let mut downloaded: u64 = 0;
|
||||||
.await
|
let mut content = Vec::with_capacity(total_size as usize);
|
||||||
.context("Failed to read asset bytes")?
|
let mut last_reported_percentage = 0;
|
||||||
.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.
|
||||||
@@ -199,6 +314,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(0) → Windows SCM / systemd reinicia el servicio con el nuevo .exe.
|
// exit(1) → Windows SCM (con failureflag=1) / systemd reinicia el servicio con el nuevo .exe.
|
||||||
std::process::exit(0);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,16 @@
|
|||||||
Compressed='yes'
|
Compressed='yes'
|
||||||
SummaryCodepage='1252' />
|
SummaryCodepage='1252' />
|
||||||
|
|
||||||
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="Una versión más reciente de [ProductName] ya está instalada." />
|
<MajorUpgrade 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'>
|
||||||
@@ -35,11 +40,12 @@
|
|||||||
Type="ownProcess"
|
Type="ownProcess"
|
||||||
Name="OmniOilEdgeAgent"
|
Name="OmniOilEdgeAgent"
|
||||||
DisplayName="OmniOil Edge Agent"
|
DisplayName="OmniOil Edge Agent"
|
||||||
Description="Edge agent for OmniOil container orchestration (v{{VERSION}})."
|
Description="Edge agent for OmniOil (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='*'>
|
||||||
@@ -78,5 +84,12 @@
|
|||||||
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,3 +16,6 @@ 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,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin events-relay
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY . .
|
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
||||||
# Copiar caché del cacher
|
COPY --from=cacher /app /app
|
||||||
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,11 +4,13 @@
|
|||||||
//! 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};
|
||||||
@@ -16,7 +18,45 @@ use tracing::{info, warn};
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.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");
|
||||||
@@ -25,7 +65,36 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Conexiones de infraestructura
|
// Conexiones de infraestructura
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(
|
||||||
|
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.");
|
||||||
@@ -59,10 +128,14 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
services/events-relay/src/metrics.rs
Normal file
26
services/events-relay/src/metrics.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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()
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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,16 +9,14 @@ 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 || isBootstrapping) {
|
if (hasBootstrapped) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +32,9 @@ export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!refreshToken || !user?.email) {
|
if (!user?.email) {
|
||||||
if (user) {
|
if (user) {
|
||||||
logout('bootstrap-missing-refresh')
|
logout('bootstrap-missing-user-email')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -62,10 +60,8 @@ export default function SessionBootstrap({ children }: SessionBootstrapProps) {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
|
||||||
user,
|
user,
|
||||||
hasBootstrapped,
|
hasBootstrapped,
|
||||||
isBootstrapping,
|
|
||||||
beginBootstrap,
|
beginBootstrap,
|
||||||
completeBootstrap,
|
completeBootstrap,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -6,11 +6,26 @@ 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()
|
||||||
@@ -42,6 +57,14 @@ 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)
|
||||||
@@ -156,6 +179,32 @@ 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('matches query-specific realtime navigation items by search params', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/realtime?view=trends']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/*" element={<AdminLayout />}>
|
||||||
|
<Route path="realtime" element={<div>Realtime operativo</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const topbar = container?.querySelector('[aria-label="Barra superior del dashboard"]')
|
||||||
|
const variablesLink = container?.querySelector<HTMLAnchorElement>('a[href="/app/admin/realtime?view=variables"]')
|
||||||
|
const trendsLink = container?.querySelector<HTMLAnchorElement>('a[href="/app/admin/realtime?view=trends"]')
|
||||||
|
|
||||||
|
expect(topbar?.textContent).toContain('Tendencias')
|
||||||
|
expect(variablesLink?.getAttribute('aria-current')).toBeNull()
|
||||||
|
expect(trendsLink?.getAttribute('aria-current')).toBe('page')
|
||||||
|
expect(variablesLink?.className).not.toContain('font-semibold')
|
||||||
|
expect(trendsLink?.className).toContain('font-semibold')
|
||||||
|
})
|
||||||
|
|
||||||
it('exposes a desktop sidebar toggle before the current location', async () => {
|
it('exposes a desktop sidebar toggle before the current location', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(
|
root?.render(
|
||||||
@@ -212,6 +261,79 @@ 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')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, NavLink, Link, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
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'
|
||||||
@@ -12,13 +12,18 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Activity,
|
Activity,
|
||||||
|
Table2,
|
||||||
|
LineChart,
|
||||||
Users,
|
Users,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
CreditCard
|
CreditCard,
|
||||||
|
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'
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
||||||
|
|
||||||
@@ -26,6 +31,19 @@ function isPlatformGlobalRole(role?: string | null) {
|
|||||||
return role === 'platform_admin' || role === 'platform_operator'
|
return role === 'platform_admin' || role === 'platform_operator'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNavItemActive(to: string, location: Location) {
|
||||||
|
const [path, query] = to.split('?')
|
||||||
|
const pathMatches = location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||||
|
|
||||||
|
if (!pathMatches) return false
|
||||||
|
if (!query) return true
|
||||||
|
|
||||||
|
const expectedParams = new URLSearchParams(query)
|
||||||
|
const currentParams = new URLSearchParams(location.search)
|
||||||
|
|
||||||
|
return Array.from(expectedParams.entries()).every(([key, value]) => currentParams.get(key) === value)
|
||||||
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
@@ -50,7 +68,9 @@ export const navSections: NavSection[] = [
|
|||||||
label: 'Operaciones',
|
label: 'Operaciones',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] },
|
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] },
|
||||||
{ to: '/app/admin/realtime', label: 'Telemetria', icon: Activity, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/realtime?view=variables', label: 'Variables', icon: Table2, roles: ['admin', 'superadmin'] },
|
||||||
|
{ to: '/app/admin/realtime?view=trends', label: 'Tendencias', icon: LineChart, roles: ['admin', 'superadmin'] },
|
||||||
|
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,7 +78,6 @@ export const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] },
|
||||||
{ to: '/app/admin/settings', label: 'Ajustes', icon: Settings, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/settings', label: 'Ajustes', icon: Settings, roles: ['admin', 'superadmin'] },
|
||||||
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, roles: ['admin', 'superadmin'] },
|
|
||||||
{ to: '/app/admin/billing', label: 'Facturacion', icon: CreditCard, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/billing', label: 'Facturacion', icon: CreditCard, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -76,6 +95,8 @@ export default function AdminLayout() {
|
|||||||
const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(() => {
|
const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
||||||
})
|
})
|
||||||
|
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
|
||||||
|
const [isWellsNavOpen, setIsWellsNavOpen] = useState(true)
|
||||||
|
|
||||||
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
||||||
|
|
||||||
@@ -85,7 +106,25 @@ export default function AdminLayout() {
|
|||||||
refetchInterval: 60000,
|
refetchInterval: 60000,
|
||||||
enabled: canFetchAlarms,
|
enabled: canFetchAlarms,
|
||||||
})
|
})
|
||||||
|
const sidebarProjectsQuery = useQuery({
|
||||||
|
queryKey: ['admin-sidebar-projects'],
|
||||||
|
queryFn: listProjects,
|
||||||
|
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
|
||||||
|
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 sidebarWells = sidebarProjectConfigQuery.data?.assets
|
||||||
|
.map(({ asset }) => asset)
|
||||||
|
.filter((asset) => asset.asset_type === 'POZO') ?? []
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -110,17 +149,24 @@ export default function AdminLayout() {
|
|||||||
})).filter(section => section.items.length > 0)
|
})).filter(section => section.items.length > 0)
|
||||||
const activeNavItem = filteredSections
|
const activeNavItem = filteredSections
|
||||||
.flatMap((section) => section.items)
|
.flatMap((section) => section.items)
|
||||||
.find((item) => location.pathname === item.to || location.pathname.startsWith(`${item.to}/`))
|
.find((item) => isNavItemActive(item.to, location))
|
||||||
const currentSectionLabel = activeNavItem?.label ?? 'Administración'
|
const isWellRouteActive = location.pathname.startsWith('/app/admin/wells/')
|
||||||
|
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`
|
||||||
: 'Ver alertas'
|
: 'Ver alertas'
|
||||||
|
const getProjectAlarmCount = (projectId: string) => {
|
||||||
|
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
|
||||||
|
}
|
||||||
|
const getWellAlarmCount = (wellId: string) => {
|
||||||
|
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === wellId).length
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
<div className="flex h-screen overflow-hidden bg-[#090b0f] text-foreground">
|
||||||
{/* Mobile top bar */}
|
{/* Mobile top bar */}
|
||||||
<header className="md:hidden fixed top-0 w-full h-14 px-4 flex items-center justify-between border-b border-white/10 bg-card/95 backdrop-blur-md z-30">
|
<header className="md:hidden fixed top-0 w-full h-14 px-4 flex items-center justify-between border-b border-[#232831] bg-[#0b0f14]/95 backdrop-blur-md z-30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src="/omnioil-logo-transparente.svg"
|
src="/omnioil-logo-transparente.svg"
|
||||||
@@ -160,15 +206,15 @@ export default function AdminLayout() {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 md:z-20 flex w-56 shrink-0 flex-col",
|
"fixed inset-y-0 left-0 z-50 md:z-20 flex w-[216px] shrink-0 flex-col",
|
||||||
"border-r border-white/10 bg-card/95 backdrop-blur-md md:relative md:translate-x-0 transition-all duration-300 ease-out",
|
"border-r border-[#232831] bg-[#0b0f14] md:relative md:translate-x-0 transition-all duration-300 ease-out",
|
||||||
isDesktopSidebarCollapsed ? "md:w-20" : "md:w-56",
|
isDesktopSidebarCollapsed ? "md:w-20" : "md:w-[216px]",
|
||||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex h-20 shrink-0 items-center justify-between border-b border-white/10 px-4",
|
"flex h-20 shrink-0 items-center justify-between border-b border-[#232831] px-4",
|
||||||
isDesktopSidebarCollapsed && "md:justify-center md:px-3"
|
isDesktopSidebarCollapsed && "md:justify-center md:px-3"
|
||||||
)}>
|
)}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -192,7 +238,7 @@ export default function AdminLayout() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"pl-0.5 text-[9px] font-semibold text-primary/75 uppercase tracking-[0.22em] transition-opacity duration-200",
|
"pl-0.5 text-[9px] font-semibold text-primary uppercase tracking-[0.28em] transition-opacity duration-200",
|
||||||
isDesktopSidebarCollapsed && "md:sr-only"
|
isDesktopSidebarCollapsed && "md:sr-only"
|
||||||
)}>Operación centralizada</p>
|
)}>Operación centralizada</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,66 +253,205 @@ export default function AdminLayout() {
|
|||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className={cn(
|
<nav className={cn(
|
||||||
"flex-1 overflow-y-auto custom-sidebar-scroll py-3 px-2 space-y-4",
|
"flex-1 overflow-y-auto custom-sidebar-scroll px-2 py-3 space-y-5",
|
||||||
isDesktopSidebarCollapsed && "md:px-3"
|
isDesktopSidebarCollapsed && "md:px-3"
|
||||||
)}>
|
)}>
|
||||||
{filteredSections.map((section) => (
|
{filteredSections.map((section) => (
|
||||||
<div key={section.label}>
|
<div key={section.label}>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"px-3 mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider",
|
"mb-1.5 px-2 text-[10px] font-semibold text-[#8ca0bb] uppercase tracking-[0.18em]",
|
||||||
isDesktopSidebarCollapsed && "md:sr-only"
|
isDesktopSidebarCollapsed && "md:sr-only"
|
||||||
)}>
|
)}>
|
||||||
{section.label}
|
{section.label}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => {
|
||||||
|
const isProjectsItem = item.to === '/app/admin/projects'
|
||||||
|
|
||||||
|
if (isProjectsItem) {
|
||||||
|
return (
|
||||||
|
<div key={item.to}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
to={item.to}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'relative flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
'relative flex min-w-0 flex-1 items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
|
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary font-medium shadow-sm shadow-orange-500/10'
|
? 'bg-[#24282e] text-white font-semibold shadow-none [&>svg]:text-primary'
|
||||||
: 'text-muted-foreground hover:bg-primary/10 hover:text-foreground'
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{sidebarProjects.length > 0 && !isDesktopSidebarCollapsed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsProjectsNavOpen((open) => !open)}
|
||||||
|
className="flex h-8 w-7 shrink-0 items-center justify-center rounded-md text-[#8b98aa] transition-colors hover:bg-[#171b20] hover:text-white"
|
||||||
|
aria-label={isProjectsNavOpen ? 'Ocultar proyectos' : 'Mostrar proyectos'}
|
||||||
|
aria-expanded={isProjectsNavOpen}
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isProjectsNavOpen && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
|
||||||
|
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
||||||
|
{sidebarProjects.map((project) => {
|
||||||
|
const projectAlarmCount = getProjectAlarmCount(project.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={project.id}>
|
||||||
|
<NavLink
|
||||||
|
to={`/app/admin/projects/${project.id}`}
|
||||||
|
title={project.name}
|
||||||
|
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',
|
||||||
|
project.is_active ? 'bg-success' : 'bg-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.to}>
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
title={item.label}
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={isNavItemActive(item.to, location) ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
|
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
|
||||||
|
isNavItemActive(item.to, location)
|
||||||
|
? 'bg-[#24282e] text-white font-semibold shadow-none [&>svg]:text-primary'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
|
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
|
||||||
{item.to.includes('/alerts') && unacknowledgedCount > 0 && (
|
{item.to.includes('/alerts') && unacknowledgedCount > 0 && (
|
||||||
<Badge className={cn(
|
<Badge className={cn(
|
||||||
"ml-auto bg-destructive text-destructive-foreground text-[10px] border-0 px-1.5 min-w-[18px] text-center",
|
"ml-auto bg-destructive text-destructive-foreground text-[10px] border-0 px-1.5 min-w-[18px] text-center shadow-sm shadow-red-500/20",
|
||||||
isDesktopSidebarCollapsed && "md:absolute md:right-1 md:top-1 md:ml-0 md:min-w-0 md:px-1"
|
isDesktopSidebarCollapsed && "md:absolute md:right-1 md:top-1 md:ml-0 md:min-w-0 md:px-1"
|
||||||
)}>
|
)}>
|
||||||
{unacknowledgedCount}
|
{unacknowledgedCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</Link>
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="hidden md:block h-4 shrink-0 border-t border-white/10" />
|
<div className="hidden md:block h-4 shrink-0 border-t border-[#232831]" />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-14 md:pt-0">
|
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-14 md:pt-0">
|
||||||
<header
|
<header
|
||||||
aria-label="Barra superior del dashboard"
|
aria-label="Barra superior del dashboard"
|
||||||
className="hidden md:flex h-20 shrink-0 items-center justify-between gap-4 border-b border-white/10 bg-card/80 px-6 backdrop-blur-md"
|
className="hidden h-20 shrink-0 items-center justify-between gap-4 border-b border-[#232831] bg-[#0b0f14]/90 px-6 backdrop-blur-md md:flex"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDesktopSidebarCollapsed((collapsed) => !collapsed)}
|
onClick={() => setIsDesktopSidebarCollapsed((collapsed) => !collapsed)}
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-background/50 text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/10 hover:text-primary"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/30 bg-primary text-primary-foreground shadow-lg shadow-orange-500/20 transition-colors hover:bg-primary/90"
|
||||||
aria-label={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
aria-label={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
||||||
title={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
title={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
||||||
aria-pressed={isDesktopSidebarCollapsed}
|
aria-pressed={isDesktopSidebarCollapsed}
|
||||||
@@ -276,9 +461,9 @@ export default function AdminLayout() {
|
|||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 shadow-lg shadow-orange-500/10">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 shadow-lg shadow-orange-500/10">
|
||||||
<img src="/icon.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
<img src="/icon.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 border border-white/10 px-3 py-2 transition-colors hover:border-primary/40 hover:bg-primary/10 rounded-xl" data-testid="admin-current-location">
|
<div className="min-w-0 rounded-xl border border-[#2a3038] bg-[#15191e] px-3 py-2 transition-colors hover:border-primary/40 hover:bg-primary/10" data-testid="admin-current-location">
|
||||||
<div className="text-[10px] font-bold uppercase tracking-[0.26em] text-primary/80">
|
<div className="text-[10px] font-bold uppercase tracking-[0.26em] text-[#9aa8ba]">
|
||||||
Consola Admin
|
CONSOLA ADMIN
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-0.5 truncate text-2xl font-bold tracking-tight text-foreground">{currentSectionLabel}</h1>
|
<h1 className="mt-0.5 truncate text-2xl font-bold tracking-tight text-foreground">{currentSectionLabel}</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +474,7 @@ export default function AdminLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/app/admin/alerts')}
|
onClick={() => navigate('/app/admin/alerts')}
|
||||||
className="relative rounded-full border border-white/10 bg-background/50 p-2.5 text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary"
|
className="relative rounded-full border border-[#2a3038] bg-[#15191e] p-2.5 text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
aria-label={alertsButtonLabel}
|
aria-label={alertsButtonLabel}
|
||||||
title={alertsButtonLabel}
|
title={alertsButtonLabel}
|
||||||
>
|
>
|
||||||
@@ -298,7 +483,7 @@ export default function AdminLayout() {
|
|||||||
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card" />
|
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-background/50 py-1.5 pl-1.5 pr-2 shadow-sm shadow-black/10">
|
<div className="flex items-center gap-3 rounded-full border border-[#2a3038] bg-[#15191e] py-1.5 pl-1.5 pr-2 shadow-sm shadow-black/10">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-sm font-semibold text-primary">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-sm font-semibold text-primary">
|
||||||
{userInitial}
|
{userInitial}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('admin router navigation contract', () => {
|
|||||||
section.items
|
section.items
|
||||||
.map((item) => item.to)
|
.map((item) => item.to)
|
||||||
.filter((path) => path.startsWith('/app/admin/'))
|
.filter((path) => path.startsWith('/app/admin/'))
|
||||||
.map((path) => path.replace('/app/admin/', ''))
|
.map((path) => path.replace('/app/admin/', '').split('?')[0])
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(adminChildRoutePaths).toEqual(expect.arrayContaining(navigationPaths))
|
expect(adminChildRoutePaths).toEqual(expect.arrayContaining(navigationPaths))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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'
|
||||||
@@ -17,11 +18,13 @@ const ANHReportPage = lazy(() => import('@/features/admin/anh-reports/pages/ANHR
|
|||||||
const AlertsPage = lazy(() => import('@/features/admin/alerts/pages/AlertsPage'))
|
const AlertsPage = lazy(() => import('@/features/admin/alerts/pages/AlertsPage'))
|
||||||
const SettingsPage = lazy(() => import('@/features/admin/settings/pages/SettingsPage'))
|
const SettingsPage = lazy(() => import('@/features/admin/settings/pages/SettingsPage'))
|
||||||
const BillingPage = lazy(() => import('@/features/admin/billing/pages/BillingPage'))
|
const BillingPage = lazy(() => import('@/features/admin/billing/pages/BillingPage'))
|
||||||
|
const WellDashboardPage = lazy(() => import('@/features/admin/dashboard/pages/WellDashboardPage'))
|
||||||
|
|
||||||
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: <DashboardPage /> },
|
{ path: 'realtime', element: <RealtimePage /> },
|
||||||
|
{ 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 /> },
|
||||||
{ path: 'projects/:id', element: <ProjectDetailsPage /> },
|
{ path: 'projects/:id', element: <ProjectDetailsPage /> },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6 rounded-xl border border-white/10 bg-card/90 py-6 text-card-foreground shadow-2xl shadow-black/20 backdrop-blur-sm",
|
"flex flex-col gap-6 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-2xl shadow-black/20 backdrop-blur-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ describe('admin UI primitive visual language', () => {
|
|||||||
const button = container?.querySelector('[data-slot="button"]')
|
const button = container?.querySelector('[data-slot="button"]')
|
||||||
const badge = container?.querySelector('[data-slot="badge"]')
|
const badge = container?.querySelector('[data-slot="badge"]')
|
||||||
|
|
||||||
expect(card?.className).toContain('bg-card/90')
|
expect(card?.className).toContain('bg-card')
|
||||||
expect(card?.className).toContain('border-white/10')
|
expect(card?.className).toContain('border-border')
|
||||||
expect(button?.textContent).toBe('Guardar')
|
expect(button?.textContent).toBe('Guardar')
|
||||||
expect(badge?.textContent).toBe('Activo')
|
expect(badge?.textContent).toBe('Activo')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ 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: 'ABOVE_MAX' | 'BELOW_MIN' | 'TELEMETRY_SILENCE' | 'AGENT_OFFLINE' | string
|
alarm_type:
|
||||||
|
| '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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user