299 Commits

Author SHA1 Message Date
Wilman Yesid Farfan Diaz
3277d41227 feat: implement core backend-api, events-relay, telemetry-ingestor, and build-orchestrator services with prometheus monitoring infrastructure 2026-07-07 22:45:04 -05:00
9ea2f48088 refactor: remove unused WiX utility namespace and service auto-restart configuration 2026-07-07 21:38:26 -05:00
36cd78b870 refactor: encapsulate Windows named pipe creation in a synchronous function to avoid Send bound issues in async tasks 2026-07-07 21:38:26 -05:00
7a38146744 fix: define SECURITY_DESCRIPTOR_REVISION constant to support newer windows-sys versions 2026-07-07 21:38:26 -05:00
af2fc3c6f4 chore: update tray-icon and windows-sys dependencies in edge-agent 2026-07-07 21:38:26 -05:00
94fd169e20 refactor: improve edge-tray UI responsiveness, modernize IPC pipe creation, and update Windows dependencies 2026-07-07 21:38:26 -05:00
cf2448b439 chore: update Dockerfile to copy all icon files instead of a single specific one 2026-07-07 21:38:25 -05:00
henryor
9db196eea2 feat(admin): add trends detail modal 2026-07-07 21:38:25 -05:00
henryor
e5547e0134 feat(admin): paginate trends variables 2026-07-07 21:38:25 -05:00
henryor
368d9f4928 feat(admin): add trends operational summary 2026-07-07 21:38:25 -05:00
henryor
5d2cdd8ff5 fix(admin): improve trends data integrity 2026-07-07 21:38:25 -05:00
henryor
76c8ad109f fix(admin): remove variables trend column 2026-07-07 21:38:25 -05:00
7e9c317a5f feat: add application icon and implement automatic process termination for edge-tray during installation 2026-07-07 21:38:24 -05:00
588a0aab09 feat: implement real-time OTA update progress reporting and tray icon status indicators 2026-07-07 21:38:24 -05:00
0d91b49dec feat: implement granular RLS policies for operations_downtime table instead of generic helper call 2026-07-07 21:38:24 -05:00
52fc377755 refactor: migrate asset-dependent tables to custom RLS policies based on asset_id mapping 2026-07-07 21:38:24 -05:00
867ebceb4f refactor: remove RLS configuration for telemetry_alarms table in multi-tenancy migration 2026-07-07 21:38:24 -05:00
99f73f7e2a chore: remove row level security policies from telemetry_raw table 2026-07-07 21:38:24 -05:00
e5a67cab90 refactor: move ipc_protocol module declaration and optimize frontend Docker multi-stage build cache 2026-07-07 21:38:24 -05:00
1f6743590e feat: add crash reporting via MQTT, implement periodic data store-and-forward drainage, and enforce periodic signal publication in deadband filter to comply with ANH 0651 regulations. 2026-07-07 21:38:24 -05:00
henryor
bc01767106 feat(pwa): improve field measurement capture UX 2026-07-07 21:38:23 -05:00
henryor
342bd08bdd fix(pwa): limit login credential length 2026-07-07 21:38:23 -05:00
henryor
6f6ed2fa36 feat(pwa): clarify field navigation 2026-07-07 21:38:23 -05:00
henryor
2a18ada082 fix(pwa): pass asset into measurement fields 2026-07-07 21:38:23 -05:00
henryor
0ce9d2d550 feat(pwa): add field home dashboard 2026-07-07 21:38:23 -05:00
henryor
fb017396f0 fix(pwa): restore measurement fields and well copy 2026-07-07 21:38:23 -05:00
henryor
f16b112978 fix(admin): clean operator well labels 2026-07-07 21:38:23 -05:00
henryor
3e6e3c559d feat(pwa): add well-scoped operator access 2026-07-07 21:38:22 -05:00
henryor
fae8173199 feat(pwa): rebalance field layout navigation 2026-07-07 21:38:22 -05:00
henryor
5948998f3d feat(pwa): add feedback primitives 2026-07-07 21:38:22 -05:00
henryor
3815268b8a feat(pwa): add accessible form primitives 2026-07-07 21:38:22 -05:00
henryor
50bee181f6 fix(pwa): improve primary token contrast 2026-07-07 21:38:21 -05:00
henryor
da35b72f8f chore(pwa): point shadcn config at consolidated globals.css 2026-07-07 21:38:21 -05:00
henryor
2908ac0c66 fix(admin): stabilize well detail variable grid 2026-07-07 21:38:21 -05:00
henryor
43b8349447 feat(pwa): re-skin measurements feature with semantic design tokens
Replace hardcoded hex colors and raw Tailwind palette classes
(amber-*, red-*, emerald-*, slate-*) in MeasurementPage, PozoFields,
TanqueFields, DynamicManualFields, AssetSelector, MeasurementForm and
SyncStatusBadge with the semantic tokens consolidated in globals.css
(primary, status-*, card, border, muted-foreground, destructive).

SyncStatusBadge's offline state now uses the dedicated status-offline
(gray) token instead of reusing status-error (red), matching the
design's 5-state status palette. No logic or behavior changes — visual
only, matching the movements feature's existing token usage.

Adds a content-based Vitest guard (measurements-tokens.test.ts) that
fails if any hardcoded hex or raw palette class reappears in this
feature.
2026-07-07 21:38:21 -05:00
henryor
9b6b488c8a chore(pwa): delete dead config and orphaned stylesheets
tailwind.config.js is dead under Tailwind v4 (no @config directive
references it, postcss.config.js loads @tailwindcss/postcss directly,
and globals.css already defines OKLCH tokens the legacy HSL palette
never matched).

src/index.css and src/styles/index.css are orphaned duplicates of
globals.css with no imports anywhere in the app. src/App.tsx and
src/App.css are a dead mock prototype superseded by the real router.

Pre-verified zero importers for every deleted file before removal;
Vitest suite stays green.
2026-07-07 21:38:21 -05:00
henryor
319ceaae30 feat(pwa): consolidate design tokens to OmniOil Orange + status palette
Re-key --color-primary/accent/ring in globals.css to OmniOil Orange
(oklch(0.70 0.19 41) = #f97316) in both the @theme inline block and the
:root back-compat block, keeping them in lockstep.

Add 5 named status tokens (synced/pending/syncing/error/offline) with
foreground pairs, a type scale with a 16px body floor, and a 48px
touch-target spacing token. These are the foundation for the
field-design-system StatusBadge and form primitives landing in
follow-up PRs.
2026-07-07 21:38:21 -05:00
henryor
8e0a2b78bb feat(admin): link panorama well cards 2026-07-07 21:38:20 -05:00
henryor
50674b3078 fix(admin): clarify well critical alerts 2026-07-07 21:38:20 -05:00
henryor
d47360f86c fix(admin): stabilize panorama variable metrics 2026-07-07 21:38:20 -05:00
henryor
6cc6b4cc1a fix(billing): align commercial classification contract 2026-07-07 21:38:20 -05:00
henryor
7121ca9f47 feat(admin): show commercial billing breakdown 2026-07-07 21:38:20 -05:00
henryor
6a8cc55978 feat(console): classify commercial customers 2026-07-07 21:38:19 -05:00
henryor
d1adbe436a feat(billing): add commercial profile backend 2026-07-07 21:38:19 -05:00
henryor
895abef058 feat(billing): add commercial profile rules foundation 2026-07-07 21:38:19 -05:00
henryor
10e5993d2b style(admin): restyle billing page 2026-07-07 21:38:19 -05:00
henryor
ed6312f3a8 fix: stabilize post-merge validation 2026-07-07 21:38:19 -05:00
henryor
f5cea71798 feat(billing): show hf lf usage in admin 2026-07-07 21:38:19 -05:00
henryor
74517e2612 feat(billing): add hf lf usage backend 2026-07-07 21:38:18 -05:00
henryor
b084cf4f93 fix(admin): reserve well hf grid height 2026-07-07 21:38:18 -05:00
henryor
21a43f189e fix(admin): stabilize well lf readings 2026-07-07 21:38:18 -05:00
henryor
2a166396a0 fix(auth): restore sessions from refresh cookie 2026-07-07 21:38:18 -05:00
henryor
df718085cf fix(admin): match agent logs by well context 2026-07-07 21:38:18 -05:00
henryor
ef069b2ab9 fix(admin): stabilize well history charts 2026-07-07 21:38:18 -05:00
henryor
f55a022eb7 fix(admin): derive well lf captures from raw telemetry 2026-07-07 21:38:17 -05:00
henryor
b2bf8d21c8 fix(admin): show well lf captures from history 2026-07-07 21:38:17 -05:00
henryor
a18f059af1 feat(admin): optimize variables explorer 2026-07-07 21:38:17 -05:00
henryor
f0ed6166a2 feat(admin): show lf capture fields 2026-07-07 21:38:17 -05:00
henryor
1a704b5197 docs(telemetry): document origin contract 2026-07-07 21:38:16 -05:00
henryor
1aa40c9067 feat(admin): show dashboard telemetry by origin 2026-07-07 21:38:16 -05:00
henryor
39b83dbbe8 feat(pwa): sync lf writes with idempotency keys 2026-07-07 21:38:16 -05:00
henryor
db3304ed43 fix(api): dedupe movement retries by client id 2026-07-07 21:38:16 -05:00
henryor
91c7f12e25 fix(telemetry): handle compressed chunks in migration 2026-07-07 21:38:15 -05:00
henryor
240f8ac87a feat(telemetry): split silence alarms by origin 2026-07-07 21:38:15 -05:00
henryor
744e43c93f feat(telemetry): persist scada mqtt by resolved device 2026-07-07 21:38:15 -05:00
henryor
eb46661d3f fix(telemetry): persist lf source with project access 2026-07-07 21:38:15 -05:00
henryor
87d32acb30 feat(telemetry): add source contract schema 2026-07-07 21:38:14 -05:00
Wilman Yesid Farfan Diaz
3ce893f24b merge: sync dev with updated main 2026-06-26 21:58:59 -05:00
Wilman Yesid Farfan Diaz
959c89b5d3 fix(edge): resolve 0xc0000409 crash and configure auto-restart 2026-06-26 21:58:48 -05:00
Wilman Yesid Farfan Diaz
f38ca951f7 merge: sync dev with main overwriting conflicts 2026-06-26 21:58:22 -05:00
Wilman Yesid Farfan Diaz
4ccc1bbf90 fix(edge): resolve 0xc0000409 crash and configure auto-restart 2026-06-26 21:57:58 -05:00
henryor
df31ea1041 perf(admin): progressively render high-frequency variables 2026-06-26 21:24:45 -05:00
Wilman Yesid Farfan Diaz
e661caa514 feat: add multi-stage Dockerfiles for centralized service building and deployment 2026-06-26 21:16:16 -05:00
Wilman Yesid Farfan Diaz
d9daa6e6e4 feat: implement multi-stage Docker builds using cargo-chef for all backend services and optimize frontend build caching 2026-06-26 21:03:55 -05:00
henryor
5b68e2c80e fix(admin): keep variables explorer data stable 2026-06-26 20:42:48 -05:00
henryor
5b58da0f18 fix(admin): hide inactive high-frequency variables 2026-06-26 19:06:53 -05:00
henryor
70d1922906 fix(admin): sum panorama variables by well 2026-06-26 18:41:14 -05:00
henryor
d97fc76f16 docs(landing): add Spanish OmniOil Personal docs 2026-06-26 15:51:30 -05:00
henryor
eaaf368053 test(landing): enforce docs content policy 2026-06-26 15:01:12 -05:00
henryor
40cd3ece33 test(landing): cover OmniOil Personal docs 2026-06-26 14:46:17 -05:00
henryor
333c3b3b21 docs(landing): link OmniOil Personal documentation 2026-06-26 14:25:49 -05:00
henryor
5cd46f75a0 docs(landing): add OmniOil Personal docs routes 2026-06-26 09:14:36 -05:00
henryor
f250ad11de fix(admin): scope dashboard by selected well 2026-06-26 07:43:36 -05:00
c8a8089243 Merge pull request 'feat(admin): agregar panorama operacional del dashboard' (#17) from feat/admin-panorama-operations-dashboard into main
Reviewed-on: #17
2026-06-26 00:30:51 +00:00
henryor
565b0c0a61 feat(admin): add panorama operations dashboard 2026-06-25 19:27:09 -05:00
9b806d3fec Merge pull request 'feat/admin-well-dashboard-history' (#15) from feat/admin-well-dashboard-history into main
## Resumen
- Agrega la vista histórica de dashboard por pozo.
- Oculta el selector cuando no hay pozos reales configurados.
- Distingue el estado “Seleccioná un proyecto” del estado “Sin pozos configurados”.
- Preserva la ruta `/realtime` y suma `/wells/:wellId`.

## Verificación
- [x] `pnpm test -- DashboardPage WellDashboardPage`
- [x] `pnpm lint`
- [x] `git diff --check origin/main...HEAD`
2026-06-26 00:21:47 +00:00
henryor
64c42a02e4 test(admin): remove stale dashboard alarm assertion 2026-06-25 19:17:34 -05:00
henryor
338967b949 fix(admin): distinguish unresolved dashboard project 2026-06-25 19:17:34 -05:00
henryor
b331b05d55 feat(admin): add well dashboard history view 2026-06-25 19:17:34 -05:00
henryor
6dd7754206 fix(admin): hide empty well selector 2026-06-25 19:14:50 -05:00
30a16e5b6c Merge pull request 'style(admin): restyle ANH reports console' (#16) from feat/admin-anh-reports-polish into main
Reviewed-on: #16
2026-06-26 00:14:06 +00:00
e229f0ae50 Merge pull request 'feat/admin-project-config-deploy-toggle' (#14) from feat/admin-project-config-deploy-toggle into main
Reviewed-on: #14
2026-06-26 00:12:28 +00:00
47d31837e9 Merge pull request 'feat(simulator): add scalable Modbus load simulator' (#13) from feat/simulator-scalable-modbus-load into main
Reviewed-on: #13
2026-06-26 00:11:52 +00:00
04ef63537c Merge pull request 'feat/admin-realtime-telemetry' (#12) from feat/admin-realtime-telemetry into main
Reviewed-on: #12
2026-06-26 00:11:06 +00:00
henryor
ce9ad1f8f0 style(admin): restyle ANH reports console 2026-06-25 19:04:08 -05:00
henryor
d9c01647ef feat(admin): wire realtime telemetry route 2026-06-25 18:56:56 -05:00
henryor
e615b06f7b feat(admin): add realtime telemetry views 2026-06-25 18:54:40 -05:00
henryor
9559eb4e7d feat(admin): add realtime telemetry view models 2026-06-25 18:54:39 -05:00
henryor
311a5422a9 feat(admin): toggle well collector deployment 2026-06-25 18:54:38 -05:00
henryor
132be581f5 feat(admin): add wells sidebar navigation 2026-06-25 18:54:38 -05:00
henryor
c43642218e feat(admin): add full project config client 2026-06-25 18:54:37 -05:00
henryor
91181431aa feat(simulator): add scalable Modbus load simulator 2026-06-25 18:54:23 -05:00
Wilman Yesid Farfan Diaz
3721fa7d12 feat: add SMTP, 2FA, and URL configurations to environment files and include json-generator in unified Docker build 2026-06-16 17:24:01 -05:00
Wilman Yesid Farfan Diaz
d486c07f87 feat: implement optimized multi-stage MSI builder for edge-agent and simplify unified build pipeline 2026-06-16 16:22:09 -05:00
Wilman Yesid Farfan Diaz
e6e091895e feat: add multi-stage Docker builder for unified workspace services and Windows edge-agent cross-compilation 2026-06-16 16:22:09 -05:00
Wilman Yesid Farfan Diaz
9ee1e11608 build: configure cross-compilation environment for Windows with static linking and OPC-UA support 2026-06-16 16:22:08 -05:00
henryor
4125ec627b fix(anh): clarify report approval messaging 2026-06-16 16:22:08 -05:00
henryor
0d6c006297 feat(admin): configure anh report schedule 2026-06-16 16:22:08 -05:00
henryor
6c234f60fd feat(anh): run scheduled reports from database 2026-06-16 16:22:08 -05:00
henryor
3f850db0ae feat(anh): add report schedule configuration 2026-06-16 16:22:07 -05:00
Wilman Yesid Farfan Diaz
14b0a03b14 feat: implement asymmetric provisioning flow for edge agent and add installer worker for orchestration 2026-06-16 16:22:07 -05:00
henryor
f18bfe65c3 fix(admin): download anh reports with auth 2026-06-16 16:22:07 -05:00
henryor
9fdf049ea8 test(anh): harden sealed download coverage 2026-06-16 16:22:07 -05:00
henryor
13562d3298 feat(anh): seal generated report downloads 2026-06-16 16:22:06 -05:00
henryor
c71c58c5aa feat(anh): add sealed report metadata foundation 2026-06-16 16:22:06 -05:00
henryor
9f13d4f274 fix(admin): repair settings profile security 2026-06-16 16:22:06 -05:00
henryor
9e65a0b447 fix(admin): remove inactive header search 2026-06-16 16:22:06 -05:00
henryor
edcd02e469 fix(admin): scope user management by project 2026-06-16 16:22:06 -05:00
henryor
88fb707a66 fix(console): show trial and invitation expiry 2026-06-16 16:22:06 -05:00
Wilman Yesid Farfan Diaz
ea035b4b60 feat: add agent download token management and implement edge project CRUD handlers 2026-06-16 16:22:06 -05:00
henryor
be3fcf1f28 feat(landing): add trial pricing option 2026-06-16 16:22:05 -05:00
henryor
a97f968be2 feat(auth): brand trial activation email 2026-06-16 16:22:05 -05:00
henryor
0ada878809 fix(auth): dedupe access requests before unique index 2026-06-16 16:22:05 -05:00
henryor
fd4220c98d feat(auth): automate trial access approval 2026-06-16 16:22:05 -05:00
Wilman Yesid Farfan Diaz
0329296ea5 feat: implement modular multi-protocol data collector agent with support for Modbus, S7, OPC-UA, MQTT Sparkplug B, and SQL drivers. 2026-06-16 16:22:05 -05:00
henryor
bfda385620 feat(landing): mejora privacidad SEO y accesibilidad
Alinea OmniOil como servicio operado por Kyrbot Innovation S.A.S., agrega la política de privacidad y refuerza la landing con SEO, accesibilidad, reduced motion y pruebas runtime.
2026-06-16 16:22:04 -05:00
henryor
690f7ff48d fix(landing): ajusta textos técnicos de arquitectura 2026-06-16 16:22:04 -05:00
henryor
e7c142f4cb feat(landing): simplifica precios por tags 2026-06-16 16:22:04 -05:00
henryor
5734abe176 fix(landing): ajusta texto de alertas por correo 2026-06-16 16:22:04 -05:00
henryor
6e755e2b56 fix(landing): retira botón de agendar demo del hero 2026-06-16 16:22:04 -05:00
Wilman Yesid Farfan Diaz
4c7e092ffa feat: add terms and conditions page with multi-language support 2026-06-16 16:22:03 -05:00
Wilman Yesid Farfan Diaz
095ec2ff25 feat: implement ANH report management handlers and include json-generator in Docker build 2026-06-16 16:22:03 -05:00
Wilman Yesid Farfan Diaz
66229ab050 Generaci[on del json mejorado 2026-06-16 16:22:03 -05:00
henryor
83e067b334 refactor(admin): migrate user form modal to UI kit 2026-06-16 16:22:03 -05:00
henryor
c12deda9f9 refactor(admin): migrate users page to UI kit 2026-06-16 16:22:03 -05:00
henryor
c45862f49d feat(admin): add admin UI compositions 2026-06-16 16:22:03 -05:00
henryor
eec83ea63d feat(admin): add reusable UI primitives 2026-06-16 16:22:02 -05:00
henryor
5ce407d5b1 retiro imagenes antiguas 2026-06-16 16:22:02 -05:00
henryor
e1f8d968a8 refactor(admin): extract admin child routes for reuse 2026-06-16 16:22:02 -05:00
henryor
940f58ecdd test(admin): add Select component tests and update existing test coverage 2026-06-16 16:22:02 -05:00
henryor
4126ea9aa0 refactor(admin): align visual language across dashboard, users, login and UI components 2026-06-16 16:22:02 -05:00
henryor
b7c348b655 feat(admin): add desktop collapsible sidebar with user identity topbar 2026-06-16 16:22:01 -05:00
henryor
509c91321f fix(api): pass production SMTP env 2026-06-16 16:22:01 -05:00
henryor
2928da900f feat(console): manage platform clients and operators 2026-06-16 16:22:01 -05:00
henryor
0698ca2471 fix(auth): restrict access approvals 2026-06-16 16:22:01 -05:00
henryor
686704b658 fix(edge): restore MSI builder build 2026-06-16 16:22:01 -05:00
henryor
a777a99367 test(auth): verify access provisioning flows 2026-06-16 16:22:01 -05:00
henryor
13aea702f3 test(auth): wire DB integration to handlers 2026-06-16 16:22:00 -05:00
henryor
b43c41d2ae test(auth): add DB integration harness 2026-06-16 16:22:00 -05:00
henryor
0e43805154 docs(sdd): verify remaining access blockers 2026-06-16 16:22:00 -05:00
henryor
2ecca522cf fix(auth): align provisioned project defaults 2026-06-16 16:22:00 -05:00
henryor
37780092e3 docs(sdd): mark fmt blocker external 2026-06-16 16:22:00 -05:00
henryor
7e6993fc72 docs(sdd): document DB test blocker 2026-06-16 16:22:00 -05:00
henryor
8035f5005e docs(sdd): verify access-ready resend 2026-06-16 16:22:00 -05:00
henryor
7fb03039bd fix(auth): resend access-ready notifications 2026-06-16 16:21:59 -05:00
henryor
b90b91f7db docs(sdd): refresh access provisioning verification 2026-06-16 16:21:59 -05:00
henryor
fb0764bf9a docs(auth): document invitation deployment 2026-06-16 16:21:59 -05:00
henryor
86c85c4110 docs(sdd): update access provisioning verification 2026-06-16 16:21:59 -05:00
henryor
8b572457e0 test(auth): cover invitation edge cases 2026-06-16 16:21:59 -05:00
henryor
bd473719bb test(admin): cover invitation activation 2026-06-16 16:21:59 -05:00
henryor
b4dcae280a feat(console): show access provisioning status 2026-06-16 16:21:59 -05:00
henryor
0c510e120b fix(auth): handle invitation email failures 2026-06-16 16:21:58 -05:00
henryor
92675d9616 feat(auth): provision approved access requests 2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
932932e9e6 chore: update MinIO hostnames, add TOTP key, include Turnstile credentials, and configure OTA agent environment variables 2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
c9d7f82b2d fix(docker): add .dockerignore to frontend-console and landing
Build context is the service subdirectory, so Docker does not use
the root .dockerignore. Without a local .dockerignore, the local
node_modules (Windows binaries) was copied into the Alpine container,
overwriting the pnpm-installed packages and causing vite to disappear.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
5e385667e8 fix(docker): add perl + cmake for vendored OpenSSL build
OpenSSL's Configure script is written in Perl — without it vendored
openssl-sys fails with 'perl: not found' in debian:bookworm-slim images.

Affected images: build-orchestrator, edge-agent/Dockerfile.builder (cacher + builder stages)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
1652669aad fix(edge): vendored OpenSSL for opcua-support — no external OpenSSL required
- openssl = { version = '0.10', features = ['vendored'] } compiles OpenSSL
  from source as part of the Rust build; no OPENSSL_DIR or vcpkg needed
- opcua-support feature now activates both dep:opcua + dep:openssl (vendored)
- Dockerfile.builder: add cmake to cacher + builder stages (required for vendored build)
- Dockerfile.builder: set OPENSSL_NO_ASM=1 to avoid nasm dependency in cross-compile
- Dockerfile.builder: add --features edge-agent/opcua-support to production cargo builds
- build-orchestrator/Dockerfile: add cmake for runtime agent builds

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
10fce1119f refactor(edge): MQTT-only agent — remove HTTP polling, embed OTA payload in command
- Remove run_updater() periodic HTTP polling loop entirely
- Remove check_and_perform_update() and ServerSettings (api_url/api_token)
- AgentCommand::OtaUpdate now carries download_url + sha256 + asset_type
- Backend trigger_ota_update generates download URL + fetches SHA-256 before publishing MQTT
- Agent makes ONE outbound HTTPS GET only when OTA is commanded (no stored credentials)
- LogReporter::new cleaned up (removed unused api_url/api_token params)
- UpdaterSettings simplified: only prefer_msi + verify_signature remain
- Existing agent.toml with [server] or [updater] enabled keys ignored safely (serde)

Security model: MQTT is the only persistent channel. Binary download is a
single outbound HTTPS request using a temporary URL embedded in the MQTT command.
All traffic flows: Agent → MQTT broker (persistent) + Agent → Frontend/Nginx → Backend → MinIO (one-shot on OTA).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
1d1b13b478 feat(edge): OPC UA node scanner (v0.3.0)
- shared-lib: Add OpcUaScannedNode and OpcUaScanResult types to mqtt_messages
- edge-agent: Bump version 0.2.0 → 0.3.0
- edge-agent: Add optional opcua dep + opcua-support feature flag
- edge-agent/drivers/opc_ua: Add browse_nodes() with cfg-gated real impl
- edge-agent/mqtt_listener: Add ScanOpcUa command variant and MQTT handler
- edge-agent/main: Handle AgentCommand::ScanOpcUa, publish results to MQTT
- backend-api: Migration 20260511000000_opcua_scan_results.sql
- backend-api/handlers/edge_devices: trigger_opcua_scan + get_opcua_scan_result
- backend-api/main: MQTT handler for omnioil/opcua-scan/+, add API routes
- build-orchestrator: Enable opcua-support feature when building agent
- frontend-admin/variables-api: OPC UA scanner API types and functions
- frontend-admin/DeviceConfigModal: OPC UA node browser UI with polling
- .env.example.*: AGENT_LATEST_VERSION 0.2.0 → 0.3.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:57 -05:00
Wilman Yesid Farfan Diaz
d7dc59b7f3 feat: add admin dashboard modules, alerts management, and infrastructure entrypoints 2026-06-16 16:21:57 -05:00
henryor
9cfddf0be6 docs(sdd): define provisionamiento de solicitudes aprobadas 2026-06-16 16:21:57 -05:00
henryor
4f91382b5e fix(console): usa logo de footer en consola 2026-06-16 16:21:56 -05:00
henryor
e29210b16c fix(landing): corrige envio de solicitudes 2026-06-16 16:21:56 -05:00
Wilman Yesid Farfan Diaz
b8ab916c8d feat: add compliance section, pricing calculator, and supporting layout components for landing page 2026-06-16 16:21:56 -05:00
henryor
d2a3143c85 feat(console): alinea identidad visual 2026-06-16 16:21:56 -05:00
henryor
473631d329 feat(landing): simplifica contenido comercial 2026-06-16 16:21:55 -05:00
henryor
70a2282716 feat(landing): actualiza logos de marca 2026-06-16 16:21:55 -05:00
henryor
fe0176da4a feat(console): implementa 2FA TOTP 2026-06-16 16:21:55 -05:00
henryor
2bc9eb3120 docs(openspec): archiva el change console-tech-debt-cleanup
Persiste proposal, specs delta (auth, api-client, access-requests,
platform-shell, testing), design con 8 ADRs, tasks (27 items en 10 fases),
verify-report (28 tests, 0 críticos, 0 warnings tras el cierre) y
archive-report final.

Cierra el ciclo SDD del cleanup de deuda técnica del console.
2026-06-16 16:21:54 -05:00
henryor
0bb57a0749 feat(console): mejora UX de errores y consolida isActionable en solicitudes
Antes, si la API de solicitudes fallaba, el operador veía una tabla vacía
sin distinguir 'no hay solicitudes' de 'la API se cayó'. Se agrega una card
de error con botón Reintentar que invoca refetch(); las stat cards usan
fallbacks suaves cuando allRequestsQuery está en error.

RequestDetail repetía el chequeo ['pending','in_review'].includes(status)
inline en tres lugares. Ahora usa el helper isActionable que ya existía,
quedando una sola fuente de verdad para 'la solicitud todavía se puede
accionar'.

Tests cubren error UI, click de Reintentar con refetch, y el comportamiento
de los botones Aprobar/Rechazar/Revisar para los cuatro estados posibles.
También quedan pinned los flujos de éxito y error de approve, reject y
review (toast + invalidación de cache).
2026-06-16 16:21:54 -05:00
henryor
3ca1d31ede feat(console): agrega ErrorBoundary en el shell de plataforma
Un componente hijo que tira durante el render dejaba toda la consola en
blanco, sin sidebar ni botón de salir. Se agrega un ErrorBoundary nativo
(class component, sin dependencias externas) que envuelve el Outlet dentro
de PlatformLayout. El fallback queda dentro del chrome del layout para que
el operador pueda navegar o salir aunque la página interna falle.

Login no necesita boundary (es un único formulario controlado).

Tests verifican que un hijo que tira muestra el fallback, que el fallback
expone un botón funcional, y que el render normal pasa derecho cuando no
hay error.
2026-06-16 16:21:54 -05:00
henryor
3be117cb29 fix(console): hidrata ProtectedRoute antes de redirigir al login
ProtectedRoute mostraba 'Restaurando sesión...' siempre en el primer render,
incluso cuando el usuario nunca tuvo sesión, porque el state inicial siempre
arrancaba sin token. La heurística ahora consulta hasHydrated() de Zustand y
usa el user persistido (que sí sobrevive al reload) para decidir si vale la
pena intentar refresh: si nunca hubo user, redirige directo al login sin
flash.

Tests cubren el cold visit (sin user, no se llama a refresh) y el warm visit
(con user persistido, muestra el restoring y resuelve).
2026-06-16 16:21:54 -05:00
henryor
2da824f1a6 refactor(console): separa apiClient y apiClientVoid para respuestas 204
apiClient devolvía null casteado a T en respuestas 204, lo que silenciaba un
error de tipos en tiempo de compilación. La auditoría confirmó que ningún
caller actual pega a un endpoint 204, por lo que el cast era código muerto.

Se separa en dos funciones:
- apiClient<T>: devuelve T y lanza si el server responde 204 sin body.
- apiClientVoid: devuelve void en 204, para endpoints que no retornan body.

Tests cubren la deduplicación del refresh ante 401 concurrentes, el path
200-con-body, el throw en 204 y el path void de apiClientVoid.
2026-06-16 16:21:54 -05:00
henryor
89590121a6 fix(console): unifica accessToken y alinea LoginResponse con el backend
El store de auth duplicaba el token en dos campos (token y accessToken) que
quedaban siempre iguales pero con consumers divididos. Se elimina token: el
accessToken queda como única fuente de verdad.

LoginResponse refleja el contrato real del backend (services/backend-api auth
handler emite access_token primario y token como legacy compat). LoginPage lee
response.access_token directo, sin fallback. refresh_token sale del body porque
viaja en cookie HttpOnly, no en el JSON.

Tests pinean el contrato: setSession y logout no escriben token, y los campos
de LoginResponse mantienen los tipos esperados.
2026-06-16 16:21:54 -05:00
henryor
83f4c54d2c chore(console): integra vitest y testing library
Configura el runner de tests inline en vite.config.ts replicando el setup de
frontend-admin (jsdom, setupFiles, restoreMocks, clearMocks). Agrega el archivo
src/test/setup.ts con el mock de storage y la flag IS_REACT_ACT_ENVIRONMENT.

Esto habilita escribir tests RED-GREEN para los cambios de auth, api-client,
ProtectedRoute y access-requests que vienen en los siguientes commits.
2026-06-16 16:21:54 -05:00
henryor
e45b05d288 fix(deploy): fija version de pnpm en frontends 2026-06-16 16:21:54 -05:00
henryor
e6a9a40ff8 fix(db): corrige historial de migraciones 2026-06-16 16:21:53 -05:00
henryor
14aa7c252a fix(landing): optimiza memoria y marca visual 2026-06-16 16:21:53 -05:00
henryor
2e42230f54 fix(landing): simplify hero actions 2026-06-16 16:21:53 -05:00
henryor
4217e8ec39 fix(deploy): check backend health with curl 2026-06-16 16:21:53 -05:00
henryor
9cdafcd262 feat(landing): move access requests to landing 2026-06-16 16:21:52 -05:00
henryor
1c08e44f45 feat(platform): add provider control plane 2026-06-16 16:21:52 -05:00
henryor
08e097d640 docs: agrega guion de presentación 2026-06-16 16:21:51 -05:00
henryor
330fb7cb30 feat(admin): add B2B access request flow 2026-06-16 16:21:51 -05:00
henryor
5301f72e28 docs: update presentation html 2026-06-16 16:21:51 -05:00
henryor
06c81822d0 docs: add presentation html 2026-06-16 16:21:51 -05:00
Wilman Yesid Farfan Diaz
02f24f27ce docs: add technical manual and strategic roadmap for infrastructure scaling 2026-05-04 18:59:16 -05:00
Wilman Yesid Farfan Diaz
28d1a7d930 feat: implement edge project CRUD handlers and update S3 installers bucket configuration 2026-05-04 18:59:14 -05:00
Wilman Yesid Farfan Diaz
1c57abd97a feat: implement OTA update service with authenticated backend proxy for agent binaries 2026-05-04 18:59:13 -05:00
Wilman Yesid Farfan Diaz
fb0c172bb5 build: include shared-lib and SQLX offline data in backend-api build stage 2026-05-04 18:59:10 -05:00
Wilman Yesid Farfan Diaz
6f486a038b chore: remove unused configuration file 2026-05-04 18:59:05 -05:00
Wilman Yesid Farfan Diaz
78e8ecb55a feat: integrate sqlx offline mode with new database management scripts and dependencies 2026-05-04 18:59:05 -05:00
Wilman Yesid Farfan Diaz
9f3d7212ee feat: implement full-stack platform architecture including backend services, edge agents, frontend dashboard, and monitoring infrastructure 2026-05-04 18:59:00 -05:00
Wilman Yesid Farfan Diaz
c81ed907f7 feat(api): Phase 6 — OpenAPI docs, version header, enhanced health endpoint
- Add static OpenAPI 3.0.3 spec (services/backend-api/static/openapi.yaml)
  covering all ~70 endpoints with schemas, security, tags and responses
- Serve Swagger UI at GET /docs (embedded HTML, dark OmniOil theme)
- Serve raw YAML at GET /docs/openapi.yaml (include_str! at compile time)
- Add Tower middleware (api_version.rs) injecting X-API-Version: 1 and
  X-Powered-By: OmniOil/1.0 on every response
- Replace static /health handler with enhanced health_check that probes
  DB latency, tracks uptime via OnceLock, returns structured HealthResponse
- Expand API Explorer frontend: 70+ endpoints in 10 groups, PATCH color
  coding (purple), group filter pills, count badge, Swagger UI links

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 18:58:48 -05:00
Wilman Yesid Farfan Diaz
f90fb552fc feat: add multi-stage Dockerfile to cross-compile and build edge-agent MSI installer 2026-05-04 18:58:47 -05:00
Wilman Yesid Farfan Diaz
83988cfcde feat: implement individual service Dockerfiles and update unified build configuration 2026-05-04 18:58:47 -05:00
Wilman Yesid Farfan Diaz
22b39aadd3 feat: implement edge agent, build orchestrator, and backend telemetry services with multi-region MQTT support 2026-05-04 18:58:46 -05:00
Wilman Yesid Farfan Diaz
9c878a310b docs: add initial ISO 27001 compliance documentation including Statement of Applicability and reporting templates 2026-05-04 18:58:45 -05:00
Wilman Yesid Farfan Diaz
2d9ef90b09 feat: implement edge-agent service with MQTT listener for deployment signals and Windows service management 2026-05-04 18:58:44 -05:00
Wilman Yesid Farfan Diaz
e6d884ae95 feat: implement provisioning infrastructure with P256-based ECIES encryption and binary overlay support 2026-05-04 18:58:44 -05:00
Wilman Yesid Farfan Diaz
2dd1975b8d feat: introduce EdgeDevice model and update database schema to support additional industrial protocols including S7 2026-05-04 18:58:44 -05:00
Wilman Yesid Farfan Diaz
fa1be83665 feat: implement native Edge Agent service with MQTT, SQLite storage, and remote configuration management 2026-05-04 18:58:43 -05:00
Wilman Yesid Farfan Diaz
f19a13def1 feat(edge-agent): limpieza automática de logs y cola SQLite
- log_cleaner.rs: elimina archivos edge-agent.log.YYYY-MM-DD con
  más de 30 días al arrancar y cada 24h (evita llenado de disco 24/7)
- persistence_manager: cleanup_old_queue_entries() borra telemetría
  no enviada con más de 7 días (protege ante outages prolongados)
- main.rs + service_manager.rs: spawns de ambas tareas de limpieza

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 18:58:43 -05:00
Wilman Yesid Farfan Diaz
2c3dd8e8bd security: HIGH-5/6/8, MED-1/5 — rate limiting, HSTS, HttpOnly cookies, Argon2id
HIGH-5: Rate limiting por IP con governor (Docker-compatible via X-Real-IP)
  - Login/refresh: 10 req/min; Register: 5 req/min
  - services/backend-api/src/rate_limiter.rs (nuevo módulo)

HIGH-6: HSTS + X-Forwarded-Proto en Nginx; TLS terminado por Dokploy
  - infrastructure/nginx/gateway.conf

HIGH-8: Refresh token movido a cookie HttpOnly; access token solo en memoria
  - Backend: login emite cookie, refresh rota cookie, logout la borra
  - Frontend: Zustand v3 sin tokens en localStorage; credentials: include

MED-1: Eliminado PBKDF2 legacy; Argon2id m=65536 t=3 p=1 en todos los hashes
  - services/shared-lib/src/overlay.rs (imports limpiados)
  - services/backend-api/src/handlers/auth.rs (hash_password helper)

MED-5: Rotación atómica de refresh tokens con detección de replay

SECURITY_AUDIT_REPORT.md: riesgo reducido a 🟡 MEDIO

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 18:58:43 -05:00
Wilman Yesid Farfan Diaz
47d6a4e48d feat: implement edge agent service core, industrial simulators, and build orchestration tools 2026-05-04 18:58:42 -05:00
Wilman Yesid Farfan Diaz
7ccadb0272 feat: implement edge agent, build orchestrator, ANH reporting service, and Node-RED E2E test infrastructure 2026-05-04 18:58:41 -05:00
Wilman Yesid Farfan Diaz
154f183341 feat: implement base microservice architecture with relay, generator, and edge-agent services 2026-05-04 18:58:40 -05:00
d1647ddbcf feat(frontend-pwa): mejora flujo de sincronizacion y UX de campo 2026-05-04 18:58:39 -05:00
Wilman Yesid Farfan Diaz
d130ee578f feat: add project-wide Docker support including build orchestrator and frontend service containers 2026-05-04 18:58:39 -05:00
Wilman Yesid Farfan Diaz
6afacdc7ce feat: implement telemetry ingestor service and build-orchestrator image registry push functionality 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
4566a57e94 feat: implement periodic container health checker with auto-restart and rollback capabilities 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
79dd41ba6f feat: add DashboardPage and ProjectDetailsPage to frontend-admin feature modules 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
77bcde08ef chore: update MinIO configuration, adjust service dependencies, and pass S3 bucket environment variable to MQTT service 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
56bb76fdb2 feat: implement real-time dashboard page with WebSocket telemetry integration and historical data visualization 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
7a774a70fc feat: implement frontend PWA and Admin services with Vite, Docker configurations, and routing logic. 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
2a53ae7e9e feat: bootstrap initial project architecture, shared libraries, and core service modules for edge, backend, and frontend applications. 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
c97ab61512 feat: add Dockerfile for frontend-admin and configure VITE_ADMIN_APP_ORIGIN in docker-compose 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
d6bbc66676 feat: implement dashboard page with real-time monitoring and operational stats overview 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
997b6b0da8 feat: add dashboard and project details pages with domain-based routing configuration 2026-05-04 18:58:36 -05:00
Wilman Yesid Farfan Diaz
555aef226e feat: implement admin dashboard and project details pages for monitoring and asset management 2026-05-04 18:58:36 -05:00
Wilman Yesid Farfan Diaz
f9af6a552e feat: implement cross-domain routing logic and initial admin dashboard page architecture 2026-05-04 18:58:36 -05:00
Wilman Yesid Farfan Diaz
60ad7ca44f feat: initialize core services, MQTT infrastructure, and backend API with telemetry processing and watchdog monitoring 2026-05-04 18:58:35 -05:00
Wilman Yesid Farfan Diaz
de213a22f0 Reconstrucci[on validada. 2026-05-04 18:58:35 -05:00
Wilman Yesid Farfan Diaz
93307c3b8f Cargar valores por defecto.
Actualización de nuevas variables de entorno y comunicación.
2026-05-04 18:58:35 -05:00
Wilman Yesid Farfan Diaz
edc773b367 feat: implement backend API service with MQTT telemetry handling and edge system management routes 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
f43a3f202d feat: implement dynamic MQTT configuration and automated edge-agent binary injection in build orchestrator 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
a379c8dd59 Eliminacion de basura 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
02151e56dd feat: add Mosquitto entrypoint script and configuration template for dynamic PostgreSQL authentication setup 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
db259b5dbe feat: initialize core microservices architecture including telemetry, backend API, build orchestrator, edge-agent, and admin dashboard. 2026-05-04 18:58:33 -05:00
Wilman Yesid Farfan Diaz
8359ae9a06 feat: implement JWT-based authentication service and initialize core infrastructure across backend, edge-agent, and dashboard services 2026-05-04 18:58:33 -05:00
Wilman Yesid Farfan Diaz
2430419257 refactor: replace .env.example with environment-specific .env.example.docker and .env.example.local templates and update .gitignore 2026-05-04 18:58:33 -05:00
Wilman Yesid Farfan Diaz
ba0b0c633f Actualización de variables de entorno para pruebas locales 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
7d2013cedf Actualizaciones locales. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
660092a8ff Actualizaci[on de agente con la configuraci[on para despliegue. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
0092d20e35 Cambio de usuarios, inscripci[on activada. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
1c9059a0ea Despliegue completo.
Funcionalidad de despliegue por agente funcionando.
Hay que validar URL.
2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
a0cb973078 Validaci[on de reconstrucci[on hecho. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
c92e0b5c06 Actualizaci[on de los logs del sistema.
Validaci[on de los puntos de base de datos.
2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
0949f19c9d Inicializaci[on de datos 2026-05-04 18:58:31 -05:00
Wilman Yesid Farfan Diaz
3bde9096ab Actualizado sistema a inicializaci[on. 2026-05-04 18:58:31 -05:00
63ef77d1ce feat(admin): gestión de usuarios con CRUD, roles, proyectos y QR
- Backend: 7 endpoints admin (CRUD usuarios, roles, proyectos por usuario)
- AdminClaims extractor con validación de rol admin (403)
- Frontend admin: página de usuarios con filtros, modal crear/editar
- QR con deep link a PWA para compartir credenciales en campo
- PWA: prefill de email desde query params del QR
- Fix: Redis restart loop (command como array en docker-compose)
- Fix: crash ProjectDetailsPage por stage undefined
- Fix: migración audit_trail compatible con schema existente
- Cleanup: eliminado RegisterPage y ruta /register del admin
2026-05-04 18:58:31 -05:00
dae5e8bf00 fix(tests): corregir tests del flow-generator tras nuevos campos en modelos
Los structs EdgeProject y EdgeAsset fueron ampliados con campos ANH
(operador, contrato, localización, calibración) pero los tests no se
actualizaron. También se corrige la aserción de nodos modbus-read
que esperaba 2 en vez de 1 por variable.
2026-05-04 18:58:31 -05:00
Wilman Yesid Farfan Diaz
a524e52a19 Actualizaci[on de todos los elementos para completar el flujo 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
6f780cf22e Listo para pruebas de flujo. 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
0ad8fc05c1 Cambias actualizando el sistema 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
977e500bd6 Ultimo cambio 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
7efef41179 Revert "Actualizaci[on de flujo concurrente en el instalador"
This reverts commit 8f239c6ff4.
2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
accefcd375 Revert "Actualizaci[on de los archivos para el flujo"
This reverts commit c62c9991d6.
2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
f83d530c18 Actualizaci[on de los archivos para el flujo 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
58de3dc1f9 Actualizaci[on de flujo concurrente en el instalador 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
80403eeb20 Actualizada configuración de contenedores para visualizarse entre ellos 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
dcf72cef79 Actualizaci[on del instalador 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
58ebb1adf0 Correcciones en el json generator 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
e56dac1269 Actualizaci[on del metodo de sqlx 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
98a09289a6 Actualizaci[on del contrato y nombre del contrato con el operador 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
b34213c9c8 Arreglando bucle infinito 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
6e9fdc0f17 feat: implement auto-update mechanism and optimize Nginx cache-control policies for frontend services 2026-05-04 18:58:29 -05:00
a36b06bbf8 docs: agregar reporte de correccion auth mobile 2026-05-04 18:58:29 -05:00
84f5c42e5b fix(auth): deshabilitar registro publico en mobile 2026-05-04 18:58:29 -05:00
4f1f530ce1 fix(auth): completar separacion de superficies admin y campo 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
dd4511cdfc Subir cambio de red, para ver si se soluciona ailsmiento de contenedores 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
55a3c9895d feat: implement CRUD handlers and full configuration retrieval for edge projects 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
ee0ed8800a Modificaci[on en la variable de aws para evitar el panic en el contenedor 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
801c78fd57 Actualizaci[on de informacion adicional 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
9e5b69f3a2 Listo el depliegue 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
11f4757fe0 Actualizacion del agente y los reportes 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
980fbcc3b3 Actualizaci[on del telemetry para ingesta de datos 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
f3fd71e286 Actualizaci[on completa sobre seguridad y comunicaci[on segura. 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
f0ce0a67cc Cargando el doccjer cpmpse mpdificado 2026-05-04 18:58:27 -05:00
7aec3b67fc docs: add repair.md (registro usuarios) 2026-05-04 18:58:27 -05:00
0551d41475 feat(api): support comma-separated ALLOWED_ORIGINS for proper CORS in prod 2026-05-04 18:58:27 -05:00
dade0608a8 fix(gateway+pwa): map mobile.omnioil.app to PWA and set VITE_API_BASE=/api for frontend-pwa 2026-05-04 18:58:26 -05:00
ebb165bbff fix(api): re-enable public user registration endpoint (/api/auth/register) 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
6d0c432043 Actualizado todo el sistema con las mejoras en seguridad encontradas, despliegue sin migraciones, solo la inicial. 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
c72868f65b Soluci[on de seguridad de los puntos realizada 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
39eea9d6a4 Funcionalidades de seguridad implementadas. 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
d00db12ec0 Cambios para despliegue con mosquito 2026-05-04 18:58:26 -05:00
e83d88c599 docs: agregar PRD de estabilización y preparación para producción (v1.0) 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
e26af8ef37 Actualizado entrypoint.sh a versión 1.0.2 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
0b73ef5c49 Archivo mosquito cambiado. 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
f5ba0df791 Actualizaci[on archivo mosquito para la validaci[on de la comunicaci[on encriptada y aceptada entre contenedores 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
12c3564e01 Ajustes finales de comunicaci[on segura 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
a4f1fa137d Actualizaci[on de seguridad fuerte.
Actualizaci[on en el readme
2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
952514784e Base de datos para la validación de las credenciales 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
ab000b29a9 Adicion de mosquito.config 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
487b075493 Actualización. del docker 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
671a5339a9 Cambios en la seguridad y nuevo campo 2026-05-04 18:58:25 -05:00
f910768719 Merge remote-tracking branch 'origin/main' into dev 2026-04-06 08:41:51 -05:00
30eff47bec Merge pull request 'Update and syc dev branch' (#3) from main into dev
Reviewed-on: #3
2026-04-05 18:55:49 +00:00
245 changed files with 38991 additions and 4685 deletions

14
.clippy.toml Normal file
View 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

View File

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

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

View File

@@ -24,7 +24,7 @@ chrono-tz = "0.10.4"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
anyhow = "1.0.102" anyhow = "1.0.102"
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } 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.

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View 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

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

View File

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

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

3
rust-toolchain.toml Normal file
View File

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

25
scripts/dev-watch.ps1 Normal file
View 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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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#"
@@ -125,9 +191,10 @@ const ACCESS_REQUEST_SELECT: &str = r#"
inv.email_status AS invitation_email_status, inv.email_status AS invitation_email_status,
inv.email_sent_at AS invitation_email_sent_at, inv.email_sent_at AS invitation_email_sent_at,
sub.trial_ends_at AS trial_ends_at, sub.trial_ends_at AS trial_ends_at,
inv.expires_at AS invitation_expires_at, inv.expires_at AS invitation_expires_at,
inv.consumed_at AS invitation_consumed_at, inv.consumed_at AS invitation_consumed_at,
inv.revoked_at AS invitation_revoked_at inv.revoked_at AS invitation_revoked_at,
profile.commercial_profile AS commercial_profile
FROM access_requests ar FROM access_requests ar
LEFT JOIN users u ON u.id = ar.provisioned_user_id LEFT JOIN users u ON u.id = ar.provisioned_user_id
LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id LEFT JOIN edge_projects p ON p.id = ar.provisioned_project_id
@@ -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"));
}
} }

View File

@@ -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) .bind(request_id)
.await .execute(&pool)
{ .await
row .map_err(|error| {
} else { tracing::error!(request_id = %request_id, error = %error, "Commercial profile creation failed");
let _ = sqlx::query( AuthError::Internal(anyhow::anyhow!("Error al registrar perfil comercial"))
r#"UPDATE access_requests })?;
SET status = 'rejected', rejection_reason = 'auto_approval_failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'"#,
)
.bind(request_id)
.execute(&pool)
.await;
tracing::error!(request_id = %request_id, "Access request auto-approval failed");
return Err(AuthError::Internal(anyhow::anyhow!(
"Error al aprobar la prueba gratuita"
)));
};
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,

View File

@@ -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.name,
&sub,
counts,
billing,
last_snapshot,
))
.into_response();
}
let billing = calculate_current_billing(counts, sub.price_per_tag, sub.min_annual_fee);
Json(build_billing_usage_response(
project_id, project_id,
project_name: project.name, project.name,
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() .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")
);
}
}

View File

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

View File

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

View File

@@ -49,13 +49,13 @@ pub async fn trigger_ota_update(
} }
// Resolve project_id from agent_id (agents are keyed by project_id) // Resolve project_id from agent_id (agents are keyed by project_id)
let project_id: Uuid = sqlx::query_scalar("SELECT id FROM edge_projects WHERE id = $1") let project_id: Uuid = sqlx::query_scalar(
.bind(agent_id) "SELECT id FROM edge_projects WHERE id = $1"
.fetch_optional(&state.pool) )
.await? .bind(agent_id)
.ok_or_else(|| { .fetch_optional(&state.pool)
crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)) .await?
})?; .ok_or_else(|| crate::errors::AppError::NotFound(format!("Agent/project {} not found", agent_id)))?;
// Resolve target version: explicit request → AGENT_LATEST_VERSION env → error // Resolve target version: explicit request → AGENT_LATEST_VERSION env → error
let target_version = match req.version.as_deref() { let target_version = match req.version.as_deref() {
@@ -71,8 +71,8 @@ pub async fn trigger_ota_update(
// Build the download URL pointing to this backend's proxy endpoint. // Build the download URL pointing to this backend's proxy endpoint.
// The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed. // The agent makes ONE outbound HTTPS GET using this URL — no stored credentials needed.
let api_base = 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>,

View File

@@ -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,29 +34,15 @@ 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(user_id)
.bind(project_id) .bind(project_id)
.fetch_optional(&pool) .bind(user_id)
.fetch_all(&pool)
.await?; .await?;
if access.is_none() {
return Err(AppError::Forbidden(
"Sin acceso a los activos de este proyecto".to_string(),
));
}
let assets = sqlx::query_as::<_, EdgeAsset>(
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC",
)
.bind(project_id)
.fetch_all(&pool)
.await?;
Ok(Json(assets)) Ok(Json(assets))
} }

View File

@@ -9,10 +9,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
use chrono;
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono;
async fn mark_build_dispatch_failed( async fn mark_build_dispatch_failed(
pool: &PgPool, pool: &PgPool,

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::audit_helper; 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,45 +175,57 @@ 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",
) )
.bind(req.asset_id)
.bind(user_id)
.fetch_optional(&pool)
.await?; .await?;
let project_id: Uuid = match project_row { let client_id = normalize_client_id(req.client_id)?;
Some(row) => row.get("project_id"),
None => {
return Err(AppError::Forbidden(
"Sin permiso para registrar movimientos en este activo".to_string(),
));
}
};
let details = serde_json::json!({ if let Some(client_id) = client_id.as_deref() {
"observaciones": req.observaciones, let existing = sqlx::query_as::<Postgres, OperationMovement>(
"created_by": claims.sub find_existing_movement_by_client_id_sql(),
});
let movement: OperationMovement = sqlx::query_as::<Postgres, OperationMovement>(
r#"INSERT INTO operation_movements (
asset_id, movement_type, start_time, end_time, volumen_neto, details
) )
VALUES ($1, $2, $3, $4, $5, $6) .bind(client_id)
RETURNING *"#, .bind(req.asset_id)
) .fetch_optional(&pool)
.bind(req.asset_id) .await?;
.bind(req.movement_type)
.bind(req.start_time) if let Some(movement) = existing {
.bind(req.end_time) return Ok((StatusCode::OK, Json(movement)));
.bind(req.volumen_neto) }
.bind(details) }
.fetch_one(&pool)
.await?; let details = movement_details(req.observaciones, client_id.clone(), &claims.sub);
let inserted = sqlx::query_as::<Postgres, OperationMovement>(insert_movement_sql())
.bind(req.asset_id)
.bind(req.movement_type)
.bind(req.start_time)
.bind(req.end_time)
.bind(req.volumen_neto)
.bind(details)
.fetch_optional(&pool)
.await?;
let Some(movement) = inserted else {
let Some(client_id) = client_id.as_deref() else {
return Err(AppError::Database(sqlx::Error::RowNotFound));
};
let movement = sqlx::query_as::<Postgres, OperationMovement>(
find_existing_movement_by_client_id_sql(),
)
.bind(client_id)
.bind(req.asset_id)
.fetch_one(&pool)
.await?;
return Ok((StatusCode::OK, Json(movement)));
};
// ─── Auditoría ──────────────────────────────────────────────────────────── // ─── Auditoría ────────────────────────────────────────────────────────────
audit_helper::log_audit( audit_helper::log_audit(

View File

@@ -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)?;
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
(
StatusCode::UNAUTHORIZED,
"User ID inválido en token".to_string(),
)
})?;
sqlx::query( well_access::ensure_asset_access(
r#"INSERT INTO telemetry_raw (time, asset_id, data) &pool,
VALUES ($1, $2, $3) user_id,
ON CONFLICT (time, asset_id) DO NOTHING"#, req.asset_id,
"Access denied to this asset's telemetry",
) )
.bind(time)
.bind(req.asset_id)
.bind(&req.data)
.execute(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(app_error_to_tuple)?;
let result = sqlx::query(post_telemetry_insert_sql())
.bind(time)
.bind(req.asset_id)
.bind(&req.data)
.bind(source)
.bind(&req.client_id)
.bind(user_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
tracing::debug!(
asset_id = %req.asset_id,
time = %time,
"Telemetry record already exists; treating POST as idempotent"
);
}
tracing::info!( tracing::info!(
asset_id = %req.asset_id, asset_id = %req.asset_id,
time = %time, time = %time,
source = ?source,
"Telemetry record inserted" "Telemetry record inserted"
); );
@@ -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.

View File

@@ -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)) = (&params.ts, &params.sig) {
let msg = format!("{}:{}", params.version, ts);
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
return resp;
}
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
).into_response();
}
// 1. Parsear versión actual // 1. Parsear versión actual
let current = match Version::parse(&params.version) { let current = match Version::parse(&params.version) {
Ok(v) => v, Ok(v) => v,
@@ -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)) = (&params.ts, &params.sig) {
let msg = format!("{}:{}", version_clean, ts);
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
return resp;
}
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
).into_response();
}
let version = version_clean;
let minio_internal = let minio_internal =
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string()); std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());

View File

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

View File

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

View File

@@ -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...");
@@ -126,7 +116,7 @@ async fn main() {
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(); .await;
if topic.starts_with("omnioil/status/") {
// ── Estado inmediato del agente (LWT o reconexión) ─────────────
// Permite que la UI muestre "offline" en segundos, no minutos.
let project_id_str = topic
.strip_prefix("omnioil/status/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(msg) =
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
{
let status =
msg["status"].as_str().unwrap_or("offline").to_string();
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
{
let _ = sqlx::query(
r#"UPDATE edge_projects
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
WHERE id = $2"#,
)
.bind(&status)
.bind(project_uuid)
.execute(&pool_ref)
.await;
}
// Notificar UI en tiempo real
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::json!({
"type": "agent_status",
"status": status,
"timestamp": msg["timestamp"]
})
.to_string(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/health/") {
// ── Health report periódico del agente ─────────────────────────
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let project_id_str = topic
.strip_prefix("omnioil/health/")
.unwrap_or("")
.to_string();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(report) = serde_json::from_slice::<
shared_lib::mqtt_messages::HealthReport,
>(&payload_bytes)
{
let _ = sqlx::query(
"UPDATE edge_projects SET last_health_report = $1 WHERE id = $2",
)
.bind(serde_json::to_value(&report).unwrap_or_default())
.bind(report.project_id)
.execute(&pool_ref)
.await;
// Relay a UI
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::to_string(&report).unwrap_or_default(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/logs/") {
// ── Logs del agente → insertar en DB ──────────────────────────
let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(entry) = serde_json::from_slice::<
shared_lib::mqtt_messages::AgentLogEntry,
>(&payload_bytes)
{
let _ = sqlx::query(
r#"INSERT INTO agent_logs
(project_id, level, category, message, context, agent_hostname, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
)
.bind(entry.project_id)
.bind(&entry.level)
.bind(&entry.category)
.bind(&entry.message)
.bind(entry.context.unwrap_or(serde_json::json!({})))
.bind(&entry.agent_hostname)
.bind(entry.timestamp)
.execute(&pool_ref)
.await;
}
});
} else if topic.starts_with("omnioil/alarms/") {
// ── Alarma MQTT → dispatch notificaciones ─────────────────────
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
let project_id_str = topic
.strip_prefix("omnioil/alarms/")
.unwrap_or("")
.to_string();
tokio::spawn(async move {
if let Ok(event) = serde_json::from_slice::<
shared_lib::mqtt_messages::AlarmEvent,
>(&payload_bytes)
{
let alarm = notifier::AlarmNotification {
project_id: event.project_id,
asset_id: None,
variable: event
.variable_alias
.unwrap_or_else(|| "unknown".to_string()),
value: event.current_value.unwrap_or(0.0),
alarm_type: format!("{:?}", event.alarm_type),
message: event.message.unwrap_or_default(),
timestamp: event.timestamp,
};
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
} else {
tracing::warn!(
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
project_id_str
);
}
});
} else if topic.starts_with("omnioil/opcua-scan/") {
// ── OPC UA scan result from agent → store in DB ───────────────
let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(result) =
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
{
let scan_id_str = result
.get("scan_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = result
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("failed");
let error_msg = result.get("error").and_then(|v| v.as_str());
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
let _ = sqlx::query(
r#"UPDATE opcua_scan_results
SET status = $1, result = $2, error_message = $3
WHERE id = $4"#,
)
.bind(status)
.bind(&result)
.bind(error_msg)
.bind(scan_id)
.execute(&pool_ref)
.await;
tracing::info!(
"✅ OPC UA scan result stored: {} ({})",
scan_id,
status
);
}
}
});
} else if topic.starts_with("omnioil/") {
// ── Relay genérico: telemetría, alarmas, deploys → WebSocket ──
let topic_parts: Vec<&str> = topic.split('/').collect();
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
metrics::record_telemetry_received();
let decompressed =
shared_lib::compression::decompress_if_needed(&publish.payload);
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
let event = TelemetryEvent {
project_id: Arc::from(project_id.to_lowercase()),
payload: Arc::from(payload_str),
};
match tx_relay.send(event) {
Ok(receivers) => {
tracing::debug!(
"📥 MQTT relayed to {} WS listeners",
receivers
);
}
Err(e) => {
tracing::warn!("⚠️ No WS listeners: {:?}", e);
}
}
}
} else if topic.starts_with("provision/request/") {
// ── Provisioning MQTT ──────────────────────────────────────────
let project_id_str = topic
.strip_prefix("provision/request/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let client_ref = prov_mqtt_client.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Err(e) = handlers::provisioning::handle_mqtt_provisioning(
&pool_ref,
&client_ref,
&project_id_str,
&payload_bytes,
)
.await
{
tracing::error!(
"❌ Error en provisioning MQTT para proyecto {}: {}",
project_id_str,
e
);
}
});
}
}
}
Err(e) => {
tracing::error!("MQTT connection error: {:?}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}
}); });
// Suscribirse a todos los topics de omnioil y de provisioning // Suscribirse a todos los topics de omnioil y de provisioning
@@ -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"));
}
}

View File

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

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

View File

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

View File

@@ -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,73 +66,133 @@ 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())
.bind(spec.frequency_class)
.bind(&window)
.fetch_all(pool)
.await?;
for row in rows {
let proj_id: Uuid = row.get("id");
let proj_name: String = row.get("name");
let msg = format!(
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
source = spec.source,
frequency_class = spec.frequency_class,
);
let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
.bind(proj_id)
.bind(spec.alarm_type)
.fetch_optional(pool)
.await?;
if already_alerted.is_none() {
tracing::warn!(
source = spec.source,
frequency_class = spec.frequency_class,
"⚠️ Detectado silencio prolongado en project: {}",
proj_name
);
sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
)
.bind(proj_id)
.bind(spec.alarm_type)
.bind(&msg)
.bind(Utc::now())
.execute(pool)
.await?;
let alarm = AlarmNotification {
project_id: proj_id,
asset_id: None,
variable: spec.alarm_type.to_string(),
value: 0.0,
alarm_type: spec.alarm_type.to_string(),
message: msg,
timestamp: Utc::now(),
};
notifier::dispatch(pool, &alarm, tx).await;
}
}
}
Ok(())
}
fn telemetry_silence_specs() -> [SilenceSpec; 2] {
let hf_minutes = std::env::var("OMNIOIL_TELEMETRY_SILENCE_HF_MINUTES")
.ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
let lf_hours = std::env::var("OMNIOIL_TELEMETRY_SILENCE_LF_HOURS")
.ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
telemetry_silence_specs_from_values(hf_minutes, lf_hours)
}
fn telemetry_silence_specs_from_values(
hf_minutes: Option<i64>,
lf_hours: Option<i64>,
) -> [SilenceSpec; 2] {
let hf_minutes = hf_minutes.unwrap_or(10);
let lf_hours = lf_hours.unwrap_or(24);
[
SilenceSpec {
source: "SCADA",
frequency_class: "HF",
alarm_type: TELEMETRY_SILENCE_HF,
window: chrono::Duration::minutes(hf_minutes),
},
SilenceSpec {
source: "PWA|MANUAL",
frequency_class: "LF",
alarm_type: TELEMETRY_SILENCE_LF,
window: chrono::Duration::hours(lf_hours),
},
]
}
fn format_window_interval(window: chrono::Duration) -> String {
format!("{} seconds", window.num_seconds())
}
pub(crate) fn telemetry_silence_select_sql() -> &'static str {
r#"
SELECT p.id, p.name SELECT p.id, p.name
FROM edge_projects p FROM edge_projects p
WHERE p.is_active = true WHERE p.is_active = true
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM telemetry_raw t SELECT 1 FROM telemetry_raw t
WHERE t.project_id = p.id WHERE t.project_id = p.id
AND t.time > NOW() - INTERVAL '10 minutes' AND CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1
AND t.time > NOW() - ($2::text)::interval
) )
AND EXISTS ( AND EXISTS (
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
) )
"#, "#
) }
.fetch_all(pool)
.await?;
for row in rows { pub(crate) fn telemetry_silence_recent_alarm_sql() -> &'static str {
let proj_id: Uuid = row.get("id"); r#"
let proj_name: String = row.get("name");
let msg = format!(
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
proj_name
);
let already_alerted = sqlx::query(
r#"
SELECT 1 FROM telemetry_alarms SELECT 1 FROM telemetry_alarms
WHERE project_id = $1 WHERE project_id = $1
AND alarm_type = 'TELEMETRY_SILENCE' AND (
alarm_type = $2
OR ($2 = 'TELEMETRY_SILENCE_HF' AND alarm_type = 'TELEMETRY_SILENCE')
)
AND timestamp > NOW() - INTERVAL '1 hour' AND timestamp > NOW() - INTERVAL '1 hour'
LIMIT 1 LIMIT 1
"#, "#
)
.bind(proj_id)
.fetch_optional(pool)
.await?;
if already_alerted.is_none() {
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
)
.bind(proj_id)
.bind(&msg)
.bind(Utc::now())
.execute(pool)
.await?;
let alarm = AlarmNotification {
project_id: proj_id,
asset_id: None,
variable: "TELEMETRY_SILENCE".to_string(),
value: 0.0,
alarm_type: "TELEMETRY_SILENCE".to_string(),
message: msg,
timestamp: Utc::now(),
};
notifier::dispatch(pool, &alarm, tx).await;
}
}
Ok(())
} }
async fn check_unhealthy_agents( async fn check_unhealthy_agents(
@@ -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(

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

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
mod installer_worker; mod installer_worker;
mod metrics;
mod publisher; mod publisher;
use uuid::Uuid; use uuid::Uuid;
@@ -6,6 +7,8 @@ use uuid::Uuid;
use dotenvy::dotenv; use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use std::env; use std::env;
use std::net::SocketAddr;
use std::time::Duration;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created"; const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created";
@@ -125,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
@@ -138,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
{ {
@@ -206,6 +268,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let state = state_worker.clone(); let state = state_worker.clone();
if topic == PROJECT_CREATED_TOPIC { if topic == PROJECT_CREATED_TOPIC {
metrics::BUILDS_TRIGGERED.inc();
let payload_str = String::from_utf8_lossy(&payload); let payload_str = String::from_utf8_lossy(&payload);
tracing::info!("📥 Recibido Project Created Event: {}", payload_str); tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
if let Ok(event) = serde_json::from_str::< if let Ok(event) = serde_json::from_str::<
@@ -221,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: {}",
@@ -250,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 {

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -6,108 +6,200 @@
#![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";
tray_icon: Option<TrayIcon>,
quit_id: MenuId,
test_id: MenuId,
status_label: MenuItem,
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
status_rx: tokio::sync::mpsc::Receiver<String>,
}
impl ApplicationHandler for EdgeTrayApp { struct EdgeTrayApp {
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {} tray_icon: Option<TrayIcon>,
quit_id: MenuId,
fn window_event( test_id: MenuId,
&mut self, status_label: MenuItem,
_event_loop: &ActiveEventLoop, command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
_window_id: winit::window::WindowId, status_rx: tokio::sync::mpsc::Receiver<IpcMessage>,
_event: winit::event::WindowEvent, // Estados de animación y actualización
) { is_updating: bool,
animation_tick: usize,
last_tick_time: std::time::Instant,
connected_mqtt: bool,
} }
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { impl ApplicationHandler for EdgeTrayApp {
// Recibir actualizaciones de estado del backend fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
while let Ok(new_status) = self.status_rx.try_recv() {
self.status_label.set_text(new_status); fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
_event: winit::event::WindowEvent,
) {
} }
// Revisar clicks en el menú fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if let Ok(event) = MenuEvent::receiver().try_recv() { // Recibir actualizaciones de estado del backend
if event.id == self.quit_id { while let Ok(msg) = self.status_rx.try_recv() {
self.tray_icon.take(); match msg {
event_loop.exit(); IpcMessage::StatusUpdate {
} else if event.id == self.test_id { status,
let _ = self.command_tx.try_send(AgentCommand::Notify( connected_mqtt,
"¡Hola desde el Tray Icon!".to_string(), 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ú
if let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == self.quit_id {
self.tray_icon.take();
event_loop.exit();
} else if event.id == self.test_id {
let _ = self.command_tx.try_send(AgentCommand::Notify(
"¡Hola desde el Tray Icon!".to_string(),
));
}
} }
} }
} }
}
#[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);
// Setup Tray Menu // Setup Tray Menu
let tray_menu = Menu::new(); let tray_menu = Menu::new();
let status_label = MenuItem::new("Estado: Conectando...", false, None); let status_label = MenuItem::new("Estado: Conectando...", false, None);
let test_i = MenuItem::new("Probar Notificación", true, None); let test_i = MenuItem::new("Probar Notificación", true, None);
let quit_i = MenuItem::new("Salir", true, None); let quit_i = MenuItem::new("Salir", true, None);
let quit_id = quit_i.id().clone(); let quit_id = quit_i.id().clone();
let test_id = test_i.id().clone(); let test_id = test_i.id().clone();
tray_menu tray_menu
.append_items(&[ .append_items(&[
&status_label, &status_label,
&test_i, &test_i,
&PredefinedMenuItem::separator(), &PredefinedMenuItem::separator(),
&quit_i, &quit_i,
]) ])
.unwrap(); .unwrap();
let icon_path = std::env::current_exe() let icon_path = std::env::current_exe()
.unwrap() .unwrap()
.parent() .parent()
.unwrap() .unwrap()
.join("icon.ico"); .join("icon.ico");
let icon = load_icon(&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,58 +222,145 @@ 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;
}
});
let mut app = EdgeTrayApp {
tray_icon: Some(tray_icon),
quit_id,
test_id,
status_label,
command_tx,
status_rx,
is_updating: false,
animation_tick: 0,
last_tick_time: std::time::Instant::now(),
connected_mqtt: false,
};
event_loop.run_app(&mut app).unwrap();
}
fn load_icon_or_default(path: &std::path::Path, connected: bool) -> tray_icon::Icon {
if path.exists() {
if let Ok(image) = image::open(path) {
let image = image.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
if let Ok(icon) = tray_icon::Icon::from_rgba(rgba, width, height) {
return icon;
}
} }
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} }
});
let mut app = EdgeTrayApp { // Generar icono de estado elegante de 16x16 en memoria
tray_icon: Some(tray_icon), let width = 16;
quit_id, let height = 16;
test_id, let mut rgba = vec![0u8; (width * height * 4) as usize];
status_label, for y in 0..height {
command_tx, for x in 0..width {
status_rx, let idx = ((y * width + x) * 4) as usize;
};
event_loop.run_app(&mut app).unwrap(); // 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()
}
} }
fn load_icon(path: &std::path::Path) -> tray_icon::Icon { #[cfg(windows)]
let (icon_rgba, icon_width, icon_height) = { #[tokio::main]
let image = image::open(path) async fn main() {
.expect("Failed to open icon path") app::run();
.into_rgba8(); }
let (width, height) = image.dimensions();
let rgba = image.into_raw(); #[cfg(not(windows))]
(rgba, width, height) fn main() {
}; println!("System tray companion is only supported on Windows.");
tray_icon::Icon::from_rgba(icon_rgba, icon_width, icon_height).expect("Failed to open icon")
} }

View File

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

View File

@@ -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,240 +154,25 @@ 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 {
let mut driver: Box<dyn ProtocolDriver> = match device.protocol { device: device.clone(),
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => { variables,
let unit_id = device mqtt: self.mqtt_client.clone(),
.protocol_config topic: telemetry_topic.clone(),
.get("unit_id") storage: self.storage.clone(),
.and_then(|v| v.as_u64()) project_id: self.project_id,
.unwrap_or(1) as u8; deadband_pct: self.deadband_threshold_pct,
tracing::debug!( reporter: reporter.clone(),
"🔧 Initializing Modbus driver for {} at {}:{}", };
device.name,
device.host,
device.port
);
Box::new(ModbusTcpDriver::new(
&device.host,
device.port as u16,
unit_id,
))
}
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
let rack = device
.protocol_config
.get("rack")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u16;
let slot = device
.protocol_config
.get("slot")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u16;
tracing::debug!(
"🔧 Initializing S7 driver for {} at {}:{}",
device.name,
device.host,
device.port
);
Box::new(S7CommDriver::new(&device.host, rack, slot))
}
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
let endpoint = device
.protocol_config
.get("endpoint")
.and_then(|v| v.as_str())
.unwrap_or(&device.host)
.to_string();
tracing::debug!(
"🔧 Initializing OPC-UA driver for {} at {}",
device.name,
endpoint
);
Box::new(OpcUaDriver::new(&endpoint))
}
shared_lib::edge_models::devices::DeviceProtocol::MqttSparkplugB => {
let group_id = device
.protocol_config
.get("group_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let edge_node_id = device
.protocol_config
.get("edge_node_id")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::debug!(
"🔧 Initializing MQTT Sparkplug B driver for {} at {}:{} (Group: {}, Node: {})",
device.name,
device.host,
device.port,
group_id,
edge_node_id
);
Box::new(MqttSparkplugBDriver::new(
&device.host,
device.port as u16,
group_id,
edge_node_id,
))
}
shared_lib::edge_models::devices::DeviceProtocol::SqlDb => {
let conn_str = device
.protocol_config
.get("connection_string")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::debug!(
"🔧 Initializing SQL Database driver for {} with connection string: {}",
device.name,
conn_str
);
Box::new(SqlDbDriver::new(conn_str))
}
_ => {
let msg = format!(
"Unsupported protocol for device {}: {:?}",
device.name, device.protocol
);
tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
return;
}
};
let mut deadband = DeadbandFilter::new(deadband_pct); let handle = tokio::spawn(run_device_collector(spec.clone()));
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling handles.push((handle, spec));
let mut connected_notified = false;
loop {
timer.tick().await;
if !driver.is_alive() {
connected_notified = false;
let msg = format!(
"🔌 Attempting to connect to {} at {}:{}...",
device.name, device.host, device.port
);
let _ = reporter.info("COLLECTOR", &msg).await;
if let Err(e) = driver.connect().await {
let msg =
format!("❌ Connection failed for {}: {}", device.name, e);
tracing::warn!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
continue;
}
}
if !connected_notified {
let msg = format!(
"✅ Successfully connected to device '{}' at {}:{}",
device.name, device.host, device.port
);
tracing::info!("{}", msg);
let _ = reporter.info("COLLECTOR", &msg).await;
connected_notified = true;
}
let msg = format!("📡 Reading variables for {}...", device.name);
let _ = reporter.info("COLLECTOR", &msg).await;
match driver.read_variables(&variables).await {
Ok(values) => {
if !values.is_empty() {
let msg = format!(
"📥 Read successful for {}: {} values obtained",
device.name,
values.len()
);
let _ = reporter.info("COLLECTOR", &msg).await;
// Apply deadband filter: suppress numeric values that haven't
// changed beyond the configured threshold percentage.
let filtered: HashMap<String, Value> = values
.into_iter()
.filter(|(key, val)| {
match val.as_f64() {
Some(f) => deadband.should_publish(key, f),
None => true, // non-numeric values always publish
}
})
.collect();
if filtered.is_empty() {
tracing::debug!(
"🔇 All values suppressed by deadband filter for {}",
device.name
);
} else {
let payload = json!({
"project_id": project_id,
"device_name": device.name,
"values": filtered,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let payload_str = payload.to_string();
let payload_bytes = payload_str.as_bytes().to_vec();
let msg = format!(
"📤 Publishing telemetry for {} to MQTT topic: {}",
device.name, topic
);
let _ = reporter.info("MQTT", &msg).await;
if let Err(e) = mqtt
.publish(&topic, QoS::AtMostOnce, false, payload_bytes)
.await
{
let msg = format!(
"⚠️ MQTT publish failed for {}: {}. Buffering locally.",
device.name, e
);
let _ = reporter.error("MQTT", &msg).await;
let _ = storage_clone.save(&topic, &payload_str);
} else {
let _ = reporter
.info(
"MQTT",
&format!("✅ Data sent for {}", device.name),
)
.await;
}
}
} else {
let _ = reporter
.warn(
"COLLECTOR",
&format!(
"⚠️ No values returned for device {}",
device.name
),
)
.await;
}
}
Err(e) => {
let msg =
format!("❌ Error reading from device {}: {}", device.name, e);
tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
let _ = driver.disconnect().await;
connected_notified = false;
}
}
}
});
handles.push(handle);
} }
} }
@@ -320,3 +184,282 @@ impl DataCollectorManager {
let _ = log_reporter.info("DEPLOY", &msg).await; let _ = log_reporter.info("DEPLOY", &msg).await;
} }
} }
/// Loop principal de recolección para un dispositivo individual.
/// Extraído como función separada para permitir re-lanzamiento por el supervisor.
///
/// Incluye **backoff exponencial** en reconexiones:
/// 5s → 10s → 20s → 40s → 60s → 5min (máx).
/// El backoff se resetea al reconectar exitosamente.
async fn run_device_collector(spec: DeviceTaskSpec) {
let DeviceTaskSpec {
device,
variables,
mqtt,
topic,
storage,
project_id,
deadband_pct,
reporter,
} = spec;
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
let unit_id = device
.protocol_config
.get("unit_id")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u8;
tracing::debug!(
"🔧 Initializing Modbus driver for {} at {}:{}",
device.name,
device.host,
device.port
);
Box::new(ModbusTcpDriver::new(
&device.host,
device.port as u16,
unit_id,
))
}
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
let rack = device
.protocol_config
.get("rack")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u16;
let slot = device
.protocol_config
.get("slot")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u16;
tracing::debug!(
"🔧 Initializing S7 driver for {} at {}:{}",
device.name,
device.host,
device.port
);
Box::new(S7CommDriver::new(&device.host, rack, slot))
}
shared_lib::edge_models::devices::DeviceProtocol::OpcUa => {
let endpoint = device
.protocol_config
.get("endpoint")
.and_then(|v| v.as_str())
.unwrap_or(&device.host)
.to_string();
tracing::debug!(
"🔧 Initializing OPC-UA driver for {} at {}",
device.name,
endpoint
);
Box::new(OpcUaDriver::new(&endpoint))
}
shared_lib::edge_models::devices::DeviceProtocol::MqttSparkplugB => {
let group_id = device
.protocol_config
.get("group_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let edge_node_id = device
.protocol_config
.get("edge_node_id")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::debug!(
"🔧 Initializing MQTT Sparkplug B driver for {} at {}:{} (Group: {}, Node: {})",
device.name,
device.host,
device.port,
group_id,
edge_node_id
);
Box::new(MqttSparkplugBDriver::new(
&device.host,
device.port as u16,
group_id,
edge_node_id,
))
}
shared_lib::edge_models::devices::DeviceProtocol::SqlDb => {
let conn_str = device
.protocol_config
.get("connection_string")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::debug!(
"🔧 Initializing SQL Database driver for {} with connection string: {}",
device.name,
conn_str
);
Box::new(SqlDbDriver::new(conn_str))
}
other => {
// Este branch no debería alcanzarse porque filtramos arriba,
// pero lo dejamos como safety net.
tracing::error!(
"❌ Protocol {:?} reached driver init for device {} — this is a bug.",
other,
device.name
);
return;
}
};
let mut deadband = DeadbandFilter::new(deadband_pct);
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
let mut connected_notified = false;
// ─── Backoff exponencial para reconexiones ──────────────────────────
// Evita log spam y tráfico MQTT innecesario cuando un dispositivo
// está apagado permanentemente.
let mut reconnect_delay = Duration::from_secs(5);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); // 5 min max
let mut consecutive_connect_failures: u32 = 0;
loop {
timer.tick().await;
if !driver.is_alive() {
connected_notified = false;
// Aplicar backoff: esperar antes de reintentar si ya fallamos antes
if consecutive_connect_failures > 0 {
tracing::debug!(
"⏳ Backoff: waiting {:?} before reconnecting to {}",
reconnect_delay,
device.name
);
tokio::time::sleep(reconnect_delay).await;
}
let msg = format!(
"🔌 Attempting to connect to {} at {}:{}...",
device.name, device.host, device.port
);
// Solo reportar al servidor cada 5 intentos para no saturar logs
if consecutive_connect_failures % 5 == 0 {
let _ = reporter.info("COLLECTOR", &msg).await;
}
if let Err(e) = driver.connect().await {
consecutive_connect_failures += 1;
let msg = format!(
"❌ Connection failed for {} (attempt #{}): {}",
device.name, consecutive_connect_failures, e
);
tracing::warn!("{}", msg);
// Solo reportar al servidor en los primeros 3 intentos y luego cada 10
if consecutive_connect_failures <= 3 || consecutive_connect_failures % 10 == 0 {
let _ = reporter.error("COLLECTOR", &msg).await;
}
// Incrementar backoff: 5s → 10s → 20s → 40s → 60s → 300s
reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY);
continue;
}
// Conexión exitosa: resetear backoff
consecutive_connect_failures = 0;
reconnect_delay = Duration::from_secs(5);
}
if !connected_notified {
let msg = format!(
"✅ Successfully connected to device '{}' at {}:{}",
device.name, device.host, device.port
);
tracing::info!("{}", msg);
let _ = reporter.info("COLLECTOR", &msg).await;
connected_notified = true;
}
let msg = format!("📡 Reading variables for {}...", device.name);
let _ = reporter.info("COLLECTOR", &msg).await;
match driver.read_variables(&variables).await {
Ok(values) => {
if !values.is_empty() {
let msg = format!(
"📥 Read successful for {}: {} values obtained",
device.name,
values.len()
);
let _ = reporter.info("COLLECTOR", &msg).await;
// Apply deadband filter: suppress numeric values that haven't
// changed beyond the configured threshold percentage.
let filtered: HashMap<String, Value> = values
.into_iter()
.filter(|(key, val)| {
match val.as_f64() {
Some(f) => deadband.should_publish(key, f),
None => true, // non-numeric values always publish
}
})
.collect();
if filtered.is_empty() {
tracing::debug!(
"🔇 All values suppressed by deadband filter for {}",
device.name
);
} else {
let payload = json!({
"project_id": project_id,
"device_name": device.name,
"values": filtered,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let payload_str = payload.to_string();
let payload_bytes = payload_str.as_bytes().to_vec();
let msg = format!(
"📤 Publishing telemetry for {} to MQTT topic: {}",
device.name, topic
);
let _ = reporter.info("MQTT", &msg).await;
if let Err(e) = mqtt
.publish(&topic, QoS::AtMostOnce, false, payload_bytes)
.await
{
let msg = format!(
"⚠️ MQTT publish failed for {}: {}. Buffering locally.",
device.name, e
);
let _ = reporter.error("MQTT", &msg).await;
let _ = storage.save(&topic, &payload_str).await;
} else {
let _ = reporter
.info(
"MQTT",
&format!("✅ Data sent for {}", device.name),
)
.await;
}
}
} else {
let _ = reporter
.warn(
"COLLECTOR",
&format!(
"⚠️ No values returned for device {}",
device.name
),
)
.await;
}
}
Err(e) => {
let msg =
format!("❌ Error reading from device {}: {}", device.name, e);
tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
let _ = driver.disconnect().await;
connected_notified = false;
}
}
}
}

View File

@@ -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()), return Json(json!({ "status": "error", "message": e }));
);
obj.insert("is_agent_timestamped".to_string(), json!(true));
}
} }
let data_str = match serde_json::to_string(&payload) { let data_str = match serde_json::to_string(&payload) {
@@ -122,7 +122,7 @@ async fn ingest_data(
Ok(Err(e)) => { Ok(Err(e)) => {
// Error inmediato de MQTT // Error inmediato de MQTT
tracing::warn!("⚠️ MQTT error. Saving to local storage: {}", e); tracing::warn!("⚠️ MQTT error. Saving to local storage: {}", e);
if let Err(err) = state.storage.save(topic, &data_str) { 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"
);
}
}

View File

@@ -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 {
100.0
}
} else { } else {
((new_val - last) / last.abs() * 100.0).abs() ((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));
}
} }

View File

@@ -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 {
if let Some(v) = registers.first() { "HR" => {
results.insert(alias.clone(), json!(v)); 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() {
results.insert(alias.clone(), json!(v));
} }
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr), }
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr), Ok(Ok(Err(e))) => {
}, tracing::warn!("Modbus {} Exception: {} for addr {}", area, e, addr);
"IR" => match ctx.read_input_registers(addr, 1).await { }
Ok(Ok(registers)) => { Ok(Err(e)) => {
if let Some(v) = registers.first() { tracing::warn!("Modbus {} Transport error: {} for addr {}", area, e, addr);
results.insert(alias.clone(), json!(v)); had_transport_error = true;
} }
} Err(_) => {
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr), tracing::warn!(
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr), "Modbus {} read timeout (5s) for addr {} — PLC may be unresponsive",
}, area,
_ => tracing::warn!("Unsupported Modbus area: {}", area), addr
);
had_transport_error = true;
}
} }
} }
if had_transport_error {
self.consecutive_transport_errors += 1;
} else {
self.consecutive_transport_errors = 0;
}
Ok(results) Ok(results)
} }
/// Verifica si la conexión está activa Y funcional.
/// Si ha habido 3+ errores de transporte consecutivos, la conexión se
/// considera muerta y se fuerza reconexión.
fn is_alive(&self) -> bool { fn is_alive(&self) -> bool {
self.ctx.is_some() self.ctx.is_some() && self.consecutive_transport_errors < 3
} }
} }

View File

@@ -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,58 +24,124 @@ 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)?;
self.client = Some(client); // spawn_blocking mueve la operación TCP síncrona a un hilo dedicado.
tracing::info!("✅ Connected to S7 device at {}", self.host); // El timeout evita que un PLC inalcanzable congele el hilo indefinidamente.
Ok(()) 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.consecutive_errors = 0;
tracing::info!("✅ Connected to S7 device at {}", self.host);
Ok(())
}
Ok(Ok(Err(e))) => Err(e),
Ok(Err(e)) => Err(anyhow::anyhow!("S7 spawn_blocking join error: {}", e)),
Err(_) => Err(anyhow::anyhow!(
"S7 connection timeout (10s) for {}",
self.host
)),
}
} }
async fn disconnect(&mut self) -> anyhow::Result<()> { async fn disconnect(&mut self) -> anyhow::Result<()> {
self.client = None; self.client = None;
self.consecutive_errors = 0;
Ok(()) Ok(())
} }
/// Lee variables del PLC usando `spawn_blocking` + timeout.
/// `ag_read` es síncrono y puede bloquearse indefinidamente si el PLC
/// acepta la conexión TCP pero no responde a las queries.
async fn read_variables( async fn read_variables(
&mut self, &mut self,
variables: &HashMap<String, String>, variables: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Value>> { ) -> anyhow::Result<HashMap<String, Value>> {
let client = self let client = self
.client .client
.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
if address.starts_with("DB") { .iter()
let parts: Vec<&str> = address[2..].split('.').collect(); .map(|(k, v)| (k.clone(), v.clone()))
if parts.len() == 2 { .collect();
let db_number: i32 = parts[0].parse().unwrap_or(1);
if parts[1].starts_with("DBW") { let read_result = tokio::time::timeout(
let offset: i32 = parts[1][3..].parse().unwrap_or(0); std::time::Duration::from_secs(10),
let mut buffer = vec![0u8; 2]; tokio::task::spawn_blocking(move || {
if client.ag_read(db_number, offset, 2, &mut buffer).is_ok() { let mut results = HashMap::new();
let val = u16::from_be_bytes([buffer[0], buffer[1]]); let mut client = client;
results.insert(alias.clone(), json!(val));
for (alias, address) in &vars_clone {
if address.starts_with("DB") {
let parts: Vec<&str> = address[2..].split('.').collect();
if parts.len() == 2 {
let db_number: i32 = parts[0].parse().unwrap_or(1);
if parts[1].starts_with("DBW") {
let offset: i32 = parts[1][3..].parse().unwrap_or(0);
let mut buffer = vec![0u8; 2];
if client.ag_read(db_number, offset, 2, &mut buffer).is_ok() {
let val = u16::from_be_bytes([buffer[0], buffer[1]]);
results.insert(alias.clone(), json!(val));
}
}
} }
} }
} }
(client, results)
}),
)
.await;
match read_result {
Ok(Ok((client, results))) => {
self.client = Some(client);
self.consecutive_errors = 0;
Ok(results)
}
Ok(Err(e)) => {
self.consecutive_errors += 1;
Err(anyhow::anyhow!("S7 read spawn_blocking error: {}", e))
}
Err(_) => {
self.consecutive_errors += 1;
// No restauramos self.client — la conexión probablemente está muerta
Err(anyhow::anyhow!(
"S7 read timeout (10s) — PLC may be unresponsive"
))
} }
} }
Ok(results)
} }
/// Verifica si el driver tiene una conexión activa Y no ha acumulado errores.
/// 3 errores consecutivos = conexión considerada muerta → fuerza reconexión.
fn is_alive(&self) -> bool { fn is_alive(&self) -> bool {
self.client.is_some() self.client.is_some() && self.consecutive_errors < 3
} }
} }

View File

@@ -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, &current);
prev_cpu = current;
Some(usage)
}
#[cfg(target_os = "windows")]
{
get_cpu_usage_windows()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
None
}
};
// ─── Métricas de Memoria ─────────────────────────────────────────
let memory_mb = {
#[cfg(target_os = "linux")]
{
get_memory_usage_linux()
}
#[cfg(target_os = "windows")]
{
get_memory_usage_windows()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
None
}
};
// Construir reporte nativo // Construir reporte nativo
let report = HealthReport { let report = HealthReport {
project_id: project_uuid, project_id: project_uuid,
@@ -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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
//! Service Manager — Gestiona la instalación y ejecución como servicio de Windows. //! Service Manager — Gestiona la instalación y ejecución como servicio de Windows.
use anyhow::Result;
#[cfg(windows)] #[cfg(windows)]
use anyhow::Context; use anyhow::Context;
use anyhow::Result;
#[cfg(windows)] #[cfg(windows)]
use std::ffi::OsString; use std::ffi::OsString;
@@ -87,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)

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -18,12 +18,12 @@ import {
Menu, Menu,
X, X,
CreditCard, CreditCard,
ChevronDown ChevronDown,
Gauge,
} from 'lucide-react' } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils/cn' import { cn } from '@/lib/utils/cn'
import { listProjects } from '@/features/admin/projects/api/projects-api' import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
import { listAssets } from '@/features/admin/projects/api/wells-api'
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed' const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
@@ -96,7 +96,7 @@ export default function AdminLayout() {
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true' return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
}) })
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true) const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
const [collapsedSidebarProjectIds, setCollapsedSidebarProjectIds] = useState<string[]>([]) const [isWellsNavOpen, setIsWellsNavOpen] = useState(true)
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId) const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
@@ -112,25 +112,19 @@ export default function AdminLayout() {
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''), enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
staleTime: 60000, staleTime: 60000,
}) })
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
const sidebarProjects = sidebarProjectsQuery.data ?? [] const sidebarProjects = sidebarProjectsQuery.data ?? []
const sidebarProjectWellsQuery = useQuery({ const sidebarProjectId = activeProjectId ?? (sidebarProjects.length === 1 ? sidebarProjects[0]?.id : null)
queryKey: ['admin-sidebar-project-wells', sidebarProjects.map((project) => project.id)], const sidebarProjectConfigQuery = useQuery({
queryFn: async () => { queryKey: ['admin-sidebar-project-config', sidebarProjectId],
const assetsByProject = await Promise.all( queryFn: () => getProjectFullConfig(sidebarProjectId!),
sidebarProjects.map(async (project) => ({ enabled: !!accessToken && !!sidebarProjectId && ['admin', 'superadmin', 'auditor'].includes(user?.role || ''),
project,
wells: (await listAssets(project.id)).filter((asset) => asset.asset_type === 'POZO'),
}))
)
return assetsByProject
},
enabled: sidebarProjects.length > 0 && !isDesktopSidebarCollapsed,
staleTime: 60000, staleTime: 60000,
}) })
const sidebarProjectWells = sidebarProjectWellsQuery.data ?? [] 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()
@@ -156,7 +150,8 @@ export default function AdminLayout() {
const activeNavItem = filteredSections const activeNavItem = filteredSections
.flatMap((section) => section.items) .flatMap((section) => section.items)
.find((item) => isNavItemActive(item.to, location)) .find((item) => isNavItemActive(item.to, location))
const 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`
@@ -164,8 +159,8 @@ export default function AdminLayout() {
const getProjectAlarmCount = (projectId: string) => { const getProjectAlarmCount = (projectId: string) => {
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
} }
const getAssetAlarmCount = (assetId: string) => { const getWellAlarmCount = (wellId: string) => {
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === assetId).length return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === wellId).length
} }
return ( return (
@@ -311,90 +306,35 @@ export default function AdminLayout() {
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && ( {sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3"> <div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
{sidebarProjects.map((project) => { {sidebarProjects.map((project) => {
const projectWells = sidebarProjectWells.find((entry) => entry.project.id === project.id)?.wells ?? [] const projectAlarmCount = getProjectAlarmCount(project.id)
const isProjectExpanded = !collapsedSidebarProjectIds.includes(project.id)
return ( return (
<div key={project.id}> <div key={project.id}>
<div className="flex items-center gap-1"> <NavLink
{(() => { to={`/app/admin/projects/${project.id}`}
const projectAlarmCount = getProjectAlarmCount(project.id) title={project.name}
className={({ isActive }) =>
return ( cn(
<NavLink 'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
to={`/app/admin/projects/${project.id}`} isActive
title={project.name} ? 'bg-[#24282e] text-white'
className={({ isActive }) => : 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
cn(
'flex min-w-0 flex-1 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="truncate">{project.name}</span>
{projectAlarmCount > 0 && (
<Badge className="ml-auto shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
{projectAlarmCount}
</Badge>
)}
</NavLink>
) )
})()} }
>
{projectWells.length > 0 && ( <span
<button className={cn(
type="button" 'h-1.5 w-1.5 shrink-0 rounded-full',
onClick={() => setCollapsedSidebarProjectIds((ids) => project.is_active ? 'bg-success' : 'bg-muted-foreground'
ids.includes(project.id) )}
? ids.filter((id) => id !== project.id) />
: [...ids, project.id] <span className="min-w-0 flex-1 truncate">{project.name}</span>
)} {projectAlarmCount > 0 && (
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-[#8b98aa] transition-colors hover:bg-[#171b20] hover:text-white" <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">
aria-label={isProjectExpanded ? `Ocultar pozos de ${project.name}` : `Mostrar pozos de ${project.name}`} {projectAlarmCount}
aria-expanded={isProjectExpanded} </Badge>
>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isProjectExpanded && '-rotate-90')} />
</button>
)} )}
</div> </NavLink>
{projectWells.length > 0 && isProjectExpanded && (
<div className="ml-3 space-y-0.5 border-l border-[#232831] pl-2.5">
{projectWells.map((well) => {
const wellAlarmCount = getAssetAlarmCount(well.id)
return (
<NavLink
key={well.id}
to={`/app/admin/projects/${project.id}`}
title={`${well.name} · ${project.name}`}
className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[12px] text-[#9aa8ba] transition-colors hover:bg-[#24282e] hover:text-white"
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
well.is_active ? 'bg-[#00b7ff]' : 'bg-muted-foreground'
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">{well.name}</span>
{wellAlarmCount > 0 && (
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
{wellAlarmCount}
</Badge>
)}
</NavLink>
)
})}
</div>
)}
</div> </div>
) )
})} })}
@@ -435,6 +375,64 @@ export default function AdminLayout() {
) )
})} })}
</div> </div>
{section.label === 'General' && sidebarWells.length > 0 && !isDesktopSidebarCollapsed && (
<div className="mt-2 space-y-0.5">
<button
type="button"
onClick={() => setIsWellsNavOpen((open) => !open)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
isWellRouteActive
? 'bg-[#24282e] text-white font-semibold [&>svg]:text-primary'
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
)}
aria-expanded={isWellsNavOpen}
aria-controls="admin-sidebar-wells"
>
<Gauge className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 text-left">Pozos</span>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isWellsNavOpen && '-rotate-90')} />
</button>
{isWellsNavOpen && (
<div id="admin-sidebar-wells" className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
{sidebarWells.map((well) => {
const wellAlarmCount = getWellAlarmCount(well.id)
const wellLabel = well.name || well.code || well.id.slice(0, 8)
return (
<NavLink
key={well.id}
to={`/app/admin/wells/${well.id}`}
title={wellLabel}
className={({ isActive }) =>
cn(
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
isActive
? 'bg-[#24282e] text-white'
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
)
}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
well.is_active ? 'bg-success' : 'bg-muted-foreground'
)}
/>
<span className="min-w-0 flex-1 truncate">{wellLabel}</span>
{wellAlarmCount > 0 && (
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
{wellAlarmCount}
</Badge>
)}
</NavLink>
)
})}
</div>
)}
</div>
)}
</div> </div>
))} ))}
</nav> </nav>

View File

@@ -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'
@@ -22,7 +23,7 @@ const WellDashboardPage = lazy(() => import('@/features/admin/dashboard/pages/We
export const adminChildRoutes = [ export const adminChildRoutes = [
{ index: true, element: <Navigate to="dashboard" replace /> }, { index: true, element: <Navigate to="dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> }, { path: 'dashboard', element: <DashboardPage /> },
{ path: 'realtime', element: <DashboardPage /> }, { path: 'realtime', element: <RealtimePage /> },
{ path: 'wells/:wellId', element: <Suspense fallback={null}><WellDashboardPage /></Suspense> }, { path: 'wells/:wellId', element: <Suspense fallback={null}><WellDashboardPage /></Suspense> },
{ path: 'projects', element: <ProjectListPage /> }, { path: 'projects', element: <ProjectListPage /> },
{ path: 'projects/new', element: <CreateProjectPage /> }, { path: 'projects/new', element: <CreateProjectPage /> },

View File

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

View File

@@ -82,6 +82,14 @@ const correctionReport = {
object_key: 'anh/reports/ANH_2026-06-15_CORR1.json', object_key: 'anh/reports/ANH_2026-06-15_CORR1.json',
} }
const availableReport = {
...correctionReport,
id: 'report-available-anh',
filename: 'ANH_2026-06-15_AVAILABLE.json',
status: 'AVAILABLE_FOR_ANH' as const,
available_for_anh_at: '2026-06-18T13:05:00Z',
}
const defaultSchedule = { const defaultSchedule = {
id: 'schedule-1', id: 'schedule-1',
operator_name: 'Kyrbot', operator_name: 'Kyrbot',
@@ -247,6 +255,32 @@ describe('ANHReportPage audit hash and download behavior', () => {
expect(container.textContent).not.toContain('auto-envío') expect(container.textContent).not.toContain('auto-envío')
}) })
it('renders the ANH console header and report status summary cards', async () => {
vi.mocked(listANHReports).mockResolvedValue([generatedReport, { ...report, status: 'APPROVED' }, availableReport])
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ANHReportPage />
</QueryClientProvider>
)
})
await waitForText(container, 'ANH_2026-06-15_AVAILABLE.json')
expect(container.textContent).toContain('CONSOLA ADMIN')
expect(container.textContent).toContain('Telemetría de Pozos')
expect(container.textContent).toContain('CONSOLA ANH')
expect(container.textContent).toContain('Resolución 0651')
expect(container.textContent).toContain('Administración, revisión y trazabilidad de reportes JSON bajo la Resolución 0651')
const statCards = Array.from(container.querySelectorAll('article')).map((element) => element.textContent ?? '')
expect(statCards.some((text) => text.includes('3') && text.includes('Total'))).toBe(true)
expect(statCards.some((text) => text.includes('1') && text.includes('Borrador'))).toBe(true)
expect(statCards.some((text) => text.includes('1') && text.includes('Aprob.'))).toBe(true)
expect(statCards.some((text) => text.includes('1') && text.includes('Disponible'))).toBe(true)
})
it('saves schedule time and active state without exposing or sending timezone', async () => { it('saves schedule time and active state without exposing or sending timezone', async () => {
vi.mocked(updateANHReportSchedule).mockResolvedValue({ vi.mocked(updateANHReportSchedule).mockResolvedValue({
...defaultSchedule, ...defaultSchedule,

View File

@@ -406,9 +406,19 @@ export default function ANHReportPage() {
total: reports?.length ?? 0, total: reports?.length ?? 0,
generated: reports?.filter((report) => report.status === 'GENERATED').length ?? 0, generated: reports?.filter((report) => report.status === 'GENERATED').length ?? 0,
approved: reports?.filter((report) => report.status === 'APPROVED').length ?? 0, approved: reports?.filter((report) => report.status === 'APPROVED').length ?? 0,
sent: reports?.filter((report) => report.status === 'SENT' || report.status === 'DISPOSED').length ?? 0, sent: reports?.filter((report) => ['SENT', 'DISPOSED', 'AVAILABLE_FOR_ANH'].includes(report.status)).length ?? 0,
} }
const statCards = [
{ label: 'Total', value: reportStats.total, accent: 'text-white', bg: 'bg-[#0f1318]' },
{ label: 'Borrador', value: reportStats.generated, accent: 'text-[#ffb020]', bg: 'bg-[#ffb020]/10' },
{ label: 'Aprob.', value: reportStats.approved, accent: 'text-[#27d796]', bg: 'bg-[#27d796]/10' },
{ label: 'Disponible', value: reportStats.sent, accent: 'text-[#00b7ff]', bg: 'bg-[#00b7ff]/10' },
]
const panelClass = 'rounded-2xl border border-[#2a3038] bg-[#15191e] shadow-xl shadow-black/15'
const insetPanelClass = 'rounded-xl border border-[#2a3038] bg-[#0f1318]'
const getStatusBadge = (report: ANHReport) => { const getStatusBadge = (report: ANHReport) => {
switch (report.status) { switch (report.status) {
case 'GENERATED': case 'GENERATED':
@@ -432,140 +442,137 @@ export default function ANHReportPage() {
} }
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-5 bg-[#090b0f] pb-8 text-foreground">
{/* Header */} <div className="space-y-5">
<Card className="relative overflow-hidden border-border bg-card/80 shadow-2xl shadow-black/20"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#2a3038] pb-3">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.16),transparent_34%),linear-gradient(135deg,rgba(249,115,22,0.10),transparent_32%)]" /> <div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.22em] text-[#9aa8ba]">
<CardContent className="relative p-5 lg:p-6"> <span>CONSOLA ADMIN</span>
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between"> <span className="text-[#4e5a68]">/</span>
<span className="text-[#ff7a1a]">Telemetría de Pozos</span>
</div>
<Badge className="border-[#2a3038] bg-[#15191e] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">
Resolución 0651
</Badge>
</div>
{/* Header */}
<section className={cn(panelClass, 'relative overflow-hidden p-4 lg:p-5')} aria-label="Consola ANH">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,122,26,0.14),transparent_30%)]" />
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="max-w-3xl space-y-3"> <div className="max-w-3xl space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.22em] text-primary"> <div className="inline-flex items-center gap-2 rounded-full border border-[#ff7a1a]/25 bg-[#ff7a1a]/10 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.22em] text-[#ff7a1a]">
<FileJson className="h-3.5 w-3.5" /> Consola ANH <FileJson className="h-3.5 w-3.5" /> CONSOLA ANH
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-foreground md:text-3xl">Reportes ANH</h1> <h1 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">Reportes ANH</h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground"> <p className="mt-2 max-w-2xl text-sm leading-relaxed text-[#9aa8ba]">
Gestión, revisión y auditoría de archivos JSON según la Resolución 0651. La generación programada deja reportes listos para revisión, sin aprobarlos automáticamente. Administración, revisión y trazabilidad de reportes JSON bajo la Resolución 0651. La consola conserva evidencia de auditoría, permite aprobar borradores y dispone artefactos sellados para consulta ANH.
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row xl:items-center"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:min-w-[440px]">
<div className="grid grid-cols-4 gap-2 rounded-2xl border border-border/70 bg-background/60 p-2 backdrop-blur"> {statCards.map((stat) => (
<div className="min-w-16 rounded-xl bg-secondary/60 px-3 py-2 text-center"> <article key={stat.label} className={cn('rounded-xl border border-[#2a3038] p-3 text-right shadow-lg shadow-black/10', stat.bg)}>
<span className="block text-lg font-bold text-foreground">{reportStats.total}</span> <span className={cn('block text-2xl font-semibold tabular-nums', stat.accent)}>{stat.value}</span>
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Total</span> <span className="mt-1 block text-[10px] font-bold uppercase tracking-[0.16em] text-[#9aa8ba]">{stat.label}</span>
</div> </article>
<div className="min-w-16 rounded-xl bg-amber-500/10 px-3 py-2 text-center"> ))}
<span className="block text-lg font-bold text-amber-500">{reportStats.generated}</span>
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Borrador</span>
</div>
<div className="min-w-16 rounded-xl bg-emerald-500/10 px-3 py-2 text-center">
<span className="block text-lg font-bold text-emerald-500">{reportStats.approved}</span>
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Aprob.</span>
</div>
<div className="min-w-16 rounded-xl bg-blue-500/10 px-3 py-2 text-center">
<span className="block text-lg font-bold text-blue-500">{reportStats.sent}</span>
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">Disponible</span>
</div>
</div>
</div> </div>
</div> </div>
</CardContent> </section>
</Card>
<Card className="border-border bg-card/80 shadow-xl shadow-black/10"> <section className={cn(panelClass, 'p-4 lg:p-5')} aria-label="Regeneración programada">
<CardContent className="p-4 lg:p-5"> <div className="grid gap-5 xl:grid-cols-[1.05fr_1fr] xl:items-start">
<div className="grid gap-5 xl:grid-cols-[1.2fr_1fr] xl:items-start"> <div className="space-y-4">
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h2 className="text-sm font-semibold flex items-center gap-2"> <h2 className="flex items-center gap-2 text-base font-semibold text-white">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-primary/10 text-primary"> <span className="flex h-9 w-9 items-center justify-center rounded-xl border border-[#ff7a1a]/25 bg-[#ff7a1a]/10 text-[#ff7a1a]">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
</span> </span>
Regeneración programada Regeneración programada
</h2> </h2>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground"> <p className="mt-2 max-w-2xl text-sm leading-relaxed text-[#9aa8ba]">
Generación oficial diaria: 06:00 AM Colombia / 11:00Z. La regeneración intradía configurable es independiente y solo actualiza el reporte vigente si necesitás regenerarlo durante el día. Generación oficial diaria: 06:00 AM Colombia / 11:00Z. La regeneración intradía configurable es independiente y solo actualiza el reporte vigente si necesita regenerarse durante el día.
</p> </p>
</div> </div>
<Badge className={cn( <Badge className={cn(
'rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-wider', 'rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-wider',
isScheduleActive ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20 shadow-[0_0_18px_rgba(16,185,129,0.12)]' : 'bg-secondary text-muted-foreground border-border' isScheduleActive ? 'border-[#27d796]/25 bg-[#27d796]/10 text-[#27d796] shadow-[0_0_18px_rgba(39,215,150,0.12)]' : 'border-[#2a3038] bg-[#0f1318] text-[#9aa8ba]'
)}> )}>
{isScheduleActive ? 'Regeneración activa' : 'Regeneración inactiva'} {isScheduleActive ? 'Regeneración activa' : 'Regeneración inactiva'}
</Badge> </Badge>
</div> </div>
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/[0.08] p-3.5 text-xs leading-relaxed text-amber-400/95"> <div className="rounded-xl border border-[#00b7ff]/20 bg-[#00b7ff]/10 p-4 text-xs leading-relaxed text-[#8adfff]">
<div className="flex gap-2"> <div className="flex gap-3">
<Clock className="h-4 w-4 shrink-0 mt-0.5" /> <Clock className="mt-0.5 h-4 w-4 shrink-0 text-[#00b7ff]" />
<div> <div>
<span className="font-bold">Generación oficial diaria:</span> el reporte diario se crea todos los días a las <span className="font-semibold">06:00 AM Colombia / 11:00Z</span>. La regeneración intradía configurable es independiente; si encuentra un reporte en borrador, actualiza ese mismo reporte y su hora de última generación. <span className="font-bold text-white">Generación oficial diaria:</span> el reporte diario se crea todos los días a las <span className="font-semibold">06:00 AM Colombia / 11:00Z</span>. La regeneración intradía configurable es independiente; si encuentra un reporte en borrador, actualiza ese mismo reporte y su hora de última generación.
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 space-y-4"> <div className={cn(insetPanelClass, 'space-y-4 p-4')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end"> <div className="grid grid-cols-1 items-end gap-3 md:grid-cols-2">
<label className="space-y-1"> <label className="space-y-1">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Hora de regeneración</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-[#9aa8ba]">Hora de regeneración</span>
<input <input
type="time" type="time"
ref={scheduleTimeRef} ref={scheduleTimeRef}
defaultValue={scheduleTime} defaultValue={scheduleTime}
onChange={(event) => setScheduleTime(event.target.value)} onChange={(event) => setScheduleTime(event.target.value)}
onInput={(event) => setScheduleTime(event.currentTarget.value)} onInput={(event) => setScheduleTime(event.currentTarget.value)}
className="w-full bg-secondary/80 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] px-3 py-2 text-sm text-white outline-none [color-scheme:dark] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
/> />
</label> </label>
<label className="flex h-10 items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 text-sm text-muted-foreground"> <label className="flex h-10 items-center gap-2 rounded-lg border border-[#2a3038] bg-[#15191e] px-3 text-sm text-[#9aa8ba]">
<input <input
type="checkbox" type="checkbox"
ref={scheduleActiveRef} ref={scheduleActiveRef}
checked={isScheduleActive} checked={isScheduleActive}
onChange={(event) => setIsScheduleActive(event.target.checked)} onChange={(event) => setIsScheduleActive(event.target.checked)}
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-[#ff7a1a]"
/> />
Activa Activa
</label> </label>
</div> </div>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.07] p-3 text-xs leading-relaxed text-blue-300/95"> <div className="rounded-xl border border-[#00b7ff]/20 bg-[#00b7ff]/10 p-3 text-xs leading-relaxed text-[#8adfff]">
<span className="font-semibold text-foreground">Hora Colombia (America/Bogota):</span>{' '} <span className="font-semibold text-white">Hora Colombia (America/Bogota):</span>{' '}
la zona horaria es administrada por el sistema y no es editable desde esta consola. la zona horaria es administrada por el sistema y no es editable desde esta consola.
</div> </div>
<div className="rounded-xl border border-border/60 bg-secondary/20 p-3"> <div className="rounded-xl border border-[#2a3038] bg-[#15191e] p-3">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Diagnóstico operativo</h3> <h3 className="mb-3 text-[10px] font-semibold uppercase tracking-wider text-[#9aa8ba]">Diagnóstico operativo</h3>
<div className="grid gap-2 text-[10px] text-muted-foreground sm:grid-cols-3"> <div className="grid gap-2 text-[10px] text-[#9aa8ba] sm:grid-cols-3">
<div className="rounded-md border border-border/60 bg-background p-2"> <div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-3">
<span className="block font-semibold text-foreground">Última ejecución</span> <span className="block font-semibold text-white">Última ejecución</span>
{primarySchedule?.last_run_at ? new Date(primarySchedule.last_run_at).toLocaleString() : 'Sin ejecuciones'} {primarySchedule?.last_run_at ? new Date(primarySchedule.last_run_at).toLocaleString() : 'Sin ejecuciones'}
</div> </div>
<div className="rounded-md border border-border/60 bg-background p-2"> <div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-3">
<span className="block font-semibold text-foreground">Último estado</span> <span className="block font-semibold text-white">Último estado</span>
{getScheduleStatusLabel(primarySchedule?.last_status)} {getScheduleStatusLabel(primarySchedule?.last_status)}
</div> </div>
<div className="rounded-md border border-border/60 bg-background p-2"> <div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-3">
<span className="block font-semibold text-foreground">Último error</span> <span className="block font-semibold text-white">Último error</span>
{primarySchedule?.last_error || 'Sin errores registrados'} {primarySchedule?.last_error || 'Sin errores registrados'}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 border-t border-[#2a3038] pt-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[#9aa8ba]">
<span className="font-semibold text-foreground">Próxima regeneración:</span>{' '} <span className="font-semibold text-white">Próxima regeneración:</span>{' '}
{primarySchedule?.next_run_at ? new Date(primarySchedule.next_run_at).toLocaleString() : 'Pendiente de cálculo'} {primarySchedule?.next_run_at ? new Date(primarySchedule.next_run_at).toLocaleString() : 'Pendiente de cálculo'}
</p> </p>
<Button <Button
size="sm" size="sm"
onClick={handleSaveSchedule} onClick={handleSaveSchedule}
disabled={!primarySchedule || isSavingSchedule} disabled={!primarySchedule || isSavingSchedule}
className="flex items-center gap-2" className="flex items-center gap-2 bg-[#ff7a1a] text-[#090b0f] hover:bg-[#ff8d3d]"
> >
{isSavingSchedule ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />} {isSavingSchedule ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
Guardar Guardar
@@ -573,16 +580,16 @@ export default function ANHReportPage() {
</div> </div>
</div> </div>
</div> </div>
</CardContent> </section>
</Card> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{/* Historial */} {/* Historial */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="border-border"> <Card className={cn(panelClass, 'overflow-hidden')}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex flex-col gap-3 border-b border-[#2a3038] px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-sm font-semibold flex items-center gap-2"> <h2 className="flex items-center gap-2 text-sm font-semibold text-white">
<Clock className="h-4 w-4 text-muted-foreground" /> Historial de disponibilidad y aprobación <Clock className="h-4 w-4 text-[#ff7a1a]" /> Historial de disponibilidad y aprobación
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative"> <div className="relative">
@@ -593,7 +600,7 @@ export default function ANHReportPage() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onInput={(e) => setSearchTerm(e.currentTarget.value)} onInput={(e) => setSearchTerm(e.currentTarget.value)}
className="bg-secondary border border-border rounded-md py-1.5 pl-8 pr-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary w-48 transition-all" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] py-1.5 pl-8 pr-3 text-xs text-white transition-all outline-none placeholder:text-[#687485] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40 sm:w-64"
/> />
</div> </div>
</div> </div>
@@ -602,14 +609,14 @@ export default function ANHReportPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-secondary/20"> <tr className="border-b border-[#2a3038] bg-[#0f1318]">
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Archivo</th> <th className="px-4 py-3 text-left text-xs font-medium uppercase text-[#9aa8ba]">Archivo</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Estado</th> <th className="px-4 py-3 text-left text-xs font-medium uppercase text-[#9aa8ba]">Estado</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Fecha Operacional</th> <th className="px-4 py-3 text-left text-xs font-medium uppercase text-[#9aa8ba]">Fecha Operacional</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground uppercase">Acciones</th> <th className="px-4 py-3 text-right text-xs font-medium uppercase text-[#9aa8ba]">Acciones</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-[#2a3038]">
{isLoading ? ( {isLoading ? (
<tr><td colSpan={4} className="text-center py-10 text-sm text-muted-foreground">Cargando historial de reportes...</td></tr> <tr><td colSpan={4} className="text-center py-10 text-sm text-muted-foreground">Cargando historial de reportes...</td></tr>
) : filteredReports?.length === 0 ? ( ) : filteredReports?.length === 0 ? (
@@ -618,8 +625,8 @@ export default function ANHReportPage() {
<tr <tr
key={report.id} key={report.id}
className={cn( className={cn(
'hover:bg-secondary/30 cursor-pointer transition-colors', 'cursor-pointer transition-colors hover:bg-[#0f1318]',
selectedReportId === report.id && 'bg-secondary/50' selectedReportId === report.id && 'bg-[#0f1318]'
)} )}
onClick={() => { onClick={() => {
setSelectedReportId(report.id) setSelectedReportId(report.id)
@@ -630,8 +637,8 @@ export default function ANHReportPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileCode className="h-4 w-4 text-muted-foreground shrink-0" /> <FileCode className="h-4 w-4 text-muted-foreground shrink-0" />
<div> <div>
<p className="text-sm font-medium text-foreground">{report.filename}</p> <p className="text-sm font-medium text-white">{report.filename}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-[#9aa8ba]">
{(report.size_bytes / 1024).toFixed(1)} KB {(report.size_bytes / 1024).toFixed(1)} KB
{typeof report.correction_version === 'number' && report.correction_version > 0 ? ` · Corrección v${report.correction_version}` : ''} {typeof report.correction_version === 'number' && report.correction_version > 0 ? ` · Corrección v${report.correction_version}` : ''}
</p> </p>
@@ -641,7 +648,7 @@ export default function ANHReportPage() {
<td className="px-4 py-3"> <td className="px-4 py-3">
{getStatusBadge(report)} {getStatusBadge(report)}
</td> </td>
<td className="px-4 py-3 text-sm text-muted-foreground"> <td className="px-4 py-3 text-sm text-[#9aa8ba]">
<div className="space-y-0.5"> <div className="space-y-0.5">
<p>{formatOperationalDate(report.period_start)}</p> <p>{formatOperationalDate(report.period_start)}</p>
<p className="text-xs">Última generación {formatCreationTime(report.generated_at)}</p> <p className="text-xs">Última generación {formatCreationTime(report.generated_at)}</p>
@@ -651,7 +658,7 @@ export default function ANHReportPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:text-primary" className="h-8 w-8 text-[#9aa8ba] hover:bg-[#ff7a1a]/10 hover:text-[#ff7a1a]"
aria-label={`Descargar ${report.filename}`} aria-label={`Descargar ${report.filename}`}
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
@@ -672,18 +679,18 @@ export default function ANHReportPage() {
{/* Panel lateral de Vista Previa / Edición */} {/* Panel lateral de Vista Previa / Edición */}
<div> <div>
<Card className="border-border sticky top-4"> <Card className={cn(panelClass, 'sticky top-4 overflow-hidden')}>
{!selectedReportId ? ( {!selectedReportId ? (
<CardContent className="flex flex-col items-center justify-center h-96 text-center p-6"> <CardContent className="flex h-96 flex-col items-center justify-center p-6 text-center">
<FileJson className="h-12 w-12 text-muted-foreground/20 mb-3" /> <FileJson className="h-12 w-12 text-muted-foreground/20 mb-3" />
<h3 className="text-base font-semibold text-foreground mb-1">Vista Previa & Revisión</h3> <h3 className="mb-1 text-base font-semibold text-white">Vista Previa & Revisión</h3>
<p className="text-sm leading-relaxed text-muted-foreground max-w-[260px]">Selecciona un reporte de la lista para ver su estructura y aprobarlo.</p> <p className="max-w-[260px] text-sm leading-relaxed text-[#9aa8ba]">Selecciona un reporte de la lista para ver su estructura y aprobarlo.</p>
</CardContent> </CardContent>
) : ( ) : (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Detalle del Reporte</h3> <h3 className="text-base font-semibold text-white">Detalle del Reporte</h3>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setSelectedReportId(null); setIsEditing(false); }}> <Button variant="ghost" size="icon" className="h-7 w-7 text-[#9aa8ba] hover:bg-[#0f1318] hover:text-white" onClick={() => { setSelectedReportId(null); setIsEditing(false); }}>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -691,7 +698,7 @@ export default function ANHReportPage() {
{isEditing ? ( {isEditing ? (
/* Modo Edición */ /* Modo Edición */
<div className="space-y-4"> <div className="space-y-4">
<div className="p-3 rounded-lg bg-secondary/40 border border-border"> <div className={cn(insetPanelClass, 'p-3')}>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5"> <span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Edit2 className="h-3.5 w-3.5 text-primary" /> Editar JSON de Reporte <Edit2 className="h-3.5 w-3.5 text-primary" /> Editar JSON de Reporte
@@ -704,21 +711,21 @@ export default function ANHReportPage() {
<textarea <textarea
value={editContent} value={editContent}
onChange={(e) => setEditContent(e.target.value)} onChange={(e) => setEditContent(e.target.value)}
className="w-full h-96 bg-background text-emerald-400 font-mono text-sm leading-relaxed p-4 rounded-md border border-border focus:outline-none focus:ring-1 focus:ring-primary resize-none custom-scrollbar" className="h-96 w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] p-4 font-mono text-sm leading-relaxed text-emerald-400 outline-none custom-scrollbar focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
spellCheck={false} spellCheck={false}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
className="flex-1 text-xs" className="flex-1 border-[#2a3038] bg-[#0f1318] text-xs text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
onClick={() => setIsEditing(false)} onClick={() => setIsEditing(false)}
disabled={isSavingEdit} disabled={isSavingEdit}
> >
Cancelar Cancelar
</Button> </Button>
<Button <Button
className="flex-1 text-xs" className="flex-1 bg-[#ff7a1a] text-xs text-[#090b0f] hover:bg-[#ff8d3d]"
onClick={handleSaveEdit} onClick={handleSaveEdit}
disabled={isSavingEdit} disabled={isSavingEdit}
> >
@@ -739,7 +746,7 @@ export default function ANHReportPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Tarjeta Resumen y Diagnóstico de Calidad */} {/* Tarjeta Resumen y Diagnóstico de Calidad */}
{metrics && quality && ( {metrics && quality && (
<div className="p-3 rounded-lg bg-secondary/40 border border-border space-y-3"> <div className={cn(insetPanelClass, 'space-y-3 p-3')}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Diagnóstico de Calidad y Cobertura</h4> <h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Diagnóstico de Calidad y Cobertura</h4>
<Badge className={cn( <Badge className={cn(
@@ -754,7 +761,7 @@ export default function ANHReportPage() {
</Badge> </Badge>
</div> </div>
<div className="w-full bg-secondary h-1.5 rounded-full overflow-hidden border border-border/40"> <div className="h-1.5 w-full overflow-hidden rounded-full border border-[#2a3038] bg-[#090b0f]">
<div <div
className={cn( className={cn(
"h-full rounded-full transition-all duration-500", "h-full rounded-full transition-all duration-500",
@@ -769,36 +776,36 @@ export default function ANHReportPage() {
</div> </div>
<div className="grid grid-cols-3 gap-2 text-[9px]"> <div className="grid grid-cols-3 gap-2 text-[9px]">
<div className="p-2 rounded bg-background border border-border/40 text-center"> <div className="rounded border border-[#2a3038] bg-[#090b0f] p-2 text-center">
<span className="block font-bold text-foreground">{quality.nullFields}</span> <span className="block font-bold text-white">{quality.nullFields}</span>
<span className="text-muted-foreground text-[8px]">Nulos</span> <span className="text-muted-foreground text-[8px]">Nulos</span>
</div> </div>
<div className="p-2 rounded bg-background border border-border/40 text-center"> <div className="rounded border border-[#2a3038] bg-[#090b0f] p-2 text-center">
<span className="block font-bold text-foreground">{quality.emptyStrings}</span> <span className="block font-bold text-white">{quality.emptyStrings}</span>
<span className="text-muted-foreground text-[8px]">Vacíos</span> <span className="text-muted-foreground text-[8px]">Vacíos</span>
</div> </div>
<div className="p-2 rounded bg-background border border-border/40 text-center"> <div className="rounded border border-[#2a3038] bg-[#090b0f] p-2 text-center">
<span className={cn("block font-bold", quality.excepciones > 0 ? "text-amber-500" : "text-foreground")}> <span className={cn("block font-bold", quality.excepciones > 0 ? "text-amber-500" : "text-white")}>
{quality.excepciones} {quality.excepciones}
</span> </span>
<span className="text-muted-foreground text-[8px]">Excepciones</span> <span className="text-muted-foreground text-[8px]">Excepciones</span>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-[9px] pt-1 border-t border-border/20"> <div className="grid grid-cols-2 gap-2 border-t border-[#2a3038] pt-1 text-[9px]">
<div> <div>
<span className="text-muted-foreground">Registros Frecuencia:</span> <span className="text-muted-foreground">Registros Frecuencia:</span>
<span className="float-right font-bold text-foreground">{metrics.altaTanques + metrics.altaMedidores + metrics.altaPruebas}</span> <span className="float-right font-bold text-white">{metrics.altaTanques + metrics.altaMedidores + metrics.altaPruebas}</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Registros Transac:</span> <span className="text-muted-foreground">Registros Transac:</span>
<span className="float-right font-bold text-foreground">{metrics.bajaMovimientos + metrics.bajaParadas}</span> <span className="float-right font-bold text-white">{metrics.bajaMovimientos + metrics.bajaParadas}</span>
</div> </div>
</div> </div>
</div> </div>
)} )}
<div className="p-3 rounded-lg bg-secondary/40 border border-border"> <div className={cn(insetPanelClass, 'p-3')}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Hash className="h-3.5 w-3.5 text-primary" /> <Hash className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-muted-foreground">Hash interno de auditoría (SHA-384)</span> <span className="text-xs font-medium text-muted-foreground">Hash interno de auditoría (SHA-384)</span>
@@ -807,7 +814,7 @@ export default function ANHReportPage() {
Evidencia interna calculada sobre los bytes sellados; no es un campo normativo del JSON ANH. Evidencia interna calculada sobre los bytes sellados; no es un campo normativo del JSON ANH.
</p> </p>
<div className="relative group"> <div className="relative group">
<div className="bg-background rounded-md p-2 text-[10px] font-mono break-all text-muted-foreground border border-border pr-8 leading-normal"> <div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 pr-8 font-mono text-[10px] leading-normal break-all text-[#9aa8ba]">
{selectedReport?.hash_sha384} {selectedReport?.hash_sha384}
</div> </div>
<Button <Button
@@ -821,7 +828,7 @@ export default function ANHReportPage() {
</div> </div>
</div> </div>
<div className="p-3 rounded-lg bg-secondary/40 border border-border space-y-3"> <div className={cn(insetPanelClass, 'space-y-3 p-3')}>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5"> <span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-primary" /> Corrección <Clock className="h-3.5 w-3.5 text-primary" /> Corrección
@@ -834,42 +841,42 @@ export default function ANHReportPage() {
</div> </div>
<div className="grid gap-2 text-[10px] text-muted-foreground sm:grid-cols-2"> <div className="grid gap-2 text-[10px] text-muted-foreground sm:grid-cols-2">
<div className="rounded-md border border-border/60 bg-background p-2"> <div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2">
<span className="block font-semibold text-foreground">Estado</span> <span className="block font-semibold text-white">Estado</span>
{selectedReport ? getStatusLabel(selectedReport.status) : 'N/A'} {selectedReport ? getStatusLabel(selectedReport.status) : 'N/A'}
</div> </div>
<div className="rounded-md border border-border/60 bg-background p-2"> <div className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2">
<span className="block font-semibold text-foreground">Fecha límite de corrección</span> <span className="block font-semibold text-white">Fecha límite de corrección</span>
{formatDateTime(selectedReport?.correction_deadline_at)} {formatDateTime(selectedReport?.correction_deadline_at)}
</div> </div>
</div> </div>
{selectedReport?.rejection_reason && ( {selectedReport?.rejection_reason && (
<p className="text-[10px] leading-relaxed text-muted-foreground"> <p className="text-[10px] leading-relaxed text-muted-foreground">
<span className="font-semibold text-foreground">Motivo de inconsistencia:</span> {selectedReport.rejection_reason} <span className="font-semibold text-white">Motivo de inconsistencia:</span> {selectedReport.rejection_reason}
</p> </p>
)} )}
{selectedReport?.correction_reason && ( {selectedReport?.correction_reason && (
<p className="text-[10px] leading-relaxed text-muted-foreground"> <p className="text-[10px] leading-relaxed text-muted-foreground">
<span className="font-semibold text-foreground">Motivo de corrección:</span> {selectedReport.correction_reason} <span className="font-semibold text-white">Motivo de corrección:</span> {selectedReport.correction_reason}
</p> </p>
)} )}
{canRegisterInconsistency(selectedReport) && ( {canRegisterInconsistency(selectedReport) && (
<div className="space-y-2 border-t border-border/50 pt-3"> <div className="space-y-2 border-t border-[#2a3038] pt-3">
<label className="space-y-1 block"> <label className="space-y-1 block">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de rechazo o inconsistencia</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de rechazo o inconsistencia</span>
<textarea <textarea
aria-label="Motivo de rechazo o inconsistencia" aria-label="Motivo de rechazo o inconsistencia"
value={rejectionReason} value={rejectionReason}
onChange={(event) => setRejectionReason(event.target.value)} onChange={(event) => setRejectionReason(event.target.value)}
className="h-16 w-full resize-none rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary" className="h-16 w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
/> />
</label> </label>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="w-full text-xs" className="w-full border-[#2a3038] bg-[#0f1318] text-xs text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
onClick={handleRegisterRejection} onClick={handleRegisterRejection}
disabled={isRegisteringRejection || !rejectionReason.trim()} disabled={isRegisteringRejection || !rejectionReason.trim()}
> >
@@ -880,20 +887,20 @@ export default function ANHReportPage() {
)} )}
{canCreateCorrection(selectedReport) && ( {canCreateCorrection(selectedReport) && (
<div className="space-y-2 border-t border-border/50 pt-3"> <div className="space-y-2 border-t border-[#2a3038] pt-3">
<label className="space-y-1 block"> <label className="space-y-1 block">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de corrección</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Motivo de corrección</span>
<textarea <textarea
aria-label="Motivo de corrección" aria-label="Motivo de corrección"
value={correctionReason} value={correctionReason}
onChange={(event) => setCorrectionReason(event.target.value)} onChange={(event) => setCorrectionReason(event.target.value)}
className="h-16 w-full resize-none rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary" className="h-16 w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
/> />
</label> </label>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="w-full text-xs" className="w-full border-[#2a3038] bg-[#0f1318] text-xs text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
onClick={handleCreateCorrection} onClick={handleCreateCorrection}
disabled={isCreatingCorrection || !correctionReason.trim()} disabled={isCreatingCorrection || !correctionReason.trim()}
> >
@@ -904,20 +911,20 @@ export default function ANHReportPage() {
)} )}
{canMarkAvailableForANH(selectedReport) && ( {canMarkAvailableForANH(selectedReport) && (
<div className="space-y-2 border-t border-border/50 pt-3"> <div className="space-y-2 border-t border-[#2a3038] pt-3">
<label className="space-y-1 block"> <label className="space-y-1 block">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Nota de disponibilidad</span> <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Nota de disponibilidad</span>
<input <input
aria-label="Nota de disponibilidad para ANH" aria-label="Nota de disponibilidad para ANH"
value={availabilityNote} value={availabilityNote}
onChange={(event) => setAvailabilityNote(event.target.value)} onChange={(event) => setAvailabilityNote(event.target.value)}
className="w-full rounded-md border border-border bg-background p-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="Disponible para consulta ANH" placeholder="Disponible para consulta ANH"
/> />
</label> </label>
<Button <Button
size="sm" size="sm"
className="w-full text-xs" className="w-full bg-[#ff7a1a] text-xs text-[#090b0f] hover:bg-[#ff8d3d]"
onClick={handleMarkAvailableForANH} onClick={handleMarkAvailableForANH}
disabled={isMarkingAvailable} disabled={isMarkingAvailable}
> >
@@ -928,7 +935,7 @@ export default function ANHReportPage() {
)} )}
</div> </div>
<div className="p-3 rounded-lg bg-secondary/40 border border-border"> <div className={cn(insetPanelClass, 'p-3')}>
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
<Clock className="h-3.5 w-3.5 text-primary" /> <Clock className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-muted-foreground">Historial de versiones</span> <span className="text-xs font-medium text-muted-foreground">Historial de versiones</span>
@@ -942,9 +949,9 @@ export default function ANHReportPage() {
<p className="text-[10px] text-muted-foreground italic py-2">Sin historial de versiones registrado.</p> <p className="text-[10px] text-muted-foreground italic py-2">Sin historial de versiones registrado.</p>
) : ( ) : (
reportVersions.map((version) => ( reportVersions.map((version) => (
<div key={version.id} className="rounded-md border border-border bg-background p-2 text-[9px]"> <div key={version.id} className="rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-[9px]">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="font-bold text-foreground">{version.filename}</span> <span className="font-bold text-white">{version.filename}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{typeof version.correction_version === 'number' && version.correction_version > 0 {typeof version.correction_version === 'number' && version.correction_version > 0
? `Corrección v${version.correction_version}` ? `Corrección v${version.correction_version}`
@@ -958,7 +965,7 @@ export default function ANHReportPage() {
</div> </div>
</div> </div>
<div className="p-3 rounded-lg bg-secondary/40 border border-border"> <div className={cn(insetPanelClass, 'p-3')}>
<div className="flex flex-wrap items-center justify-between gap-2 mb-2"> <div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5"> <span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Eye className="h-3.5 w-3.5 text-primary" /> Vista Previa (JSON) <Eye className="h-3.5 w-3.5 text-primary" /> Vista Previa (JSON)
@@ -984,7 +991,7 @@ export default function ANHReportPage() {
)} )}
</div> </div>
</div> </div>
<div className="bg-background rounded-md p-4 h-80 overflow-y-auto custom-scrollbar border border-border font-mono text-sm leading-relaxed"> <div className="h-80 overflow-y-auto rounded-lg border border-[#2a3038] bg-[#090b0f] p-4 font-mono text-sm leading-relaxed custom-scrollbar">
{isPreviewLoading ? ( {isPreviewLoading ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 className="h-4 w-4 text-primary animate-spin" /> <Loader2 className="h-4 w-4 text-primary animate-spin" />
@@ -997,7 +1004,7 @@ export default function ANHReportPage() {
</div> </div>
</div> </div>
<div className="p-3 rounded-lg bg-secondary/40 border border-border"> <div className={cn(insetPanelClass, 'p-3')}>
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
<Clock className="h-3.5 w-3.5 text-primary" /> <Clock className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-muted-foreground">Trazabilidad y Auditoría</span> <span className="text-xs font-medium text-muted-foreground">Trazabilidad y Auditoría</span>
@@ -1011,9 +1018,9 @@ export default function ANHReportPage() {
<p className="text-[10px] text-muted-foreground italic py-2">Sin historial de auditoría registrado.</p> <p className="text-[10px] text-muted-foreground italic py-2">Sin historial de auditoría registrado.</p>
) : ( ) : (
auditLogs.map((log, index) => ( auditLogs.map((log, index) => (
<div key={index} className="bg-background rounded-md p-2 border border-border text-[9px] space-y-1"> <div key={index} className="space-y-1 rounded-lg border border-[#2a3038] bg-[#090b0f] p-2 text-[9px]">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-bold text-foreground capitalize"> <span className="font-bold text-white capitalize">
{log.action === "anh_report.generate" ? "Generación Manual" : {log.action === "anh_report.generate" ? "Generación Manual" :
log.action === "anh_report.update" ? "Modificación Borrador" : log.action === "anh_report.update" ? "Modificación Borrador" :
log.action === "anh_report.approve" ? "Aprobado" : log.action === "anh_report.approve" ? "Aprobado" :
@@ -1042,7 +1049,7 @@ export default function ANHReportPage() {
{canReviewDraft ? ( {canReviewDraft ? (
<div className="flex flex-col gap-2 pt-2"> <div className="flex flex-col gap-2 pt-2">
<Button <Button
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2 bg-emerald-600 text-white hover:bg-emerald-700"
onClick={handleApprove} onClick={handleApprove}
disabled={isApproving} disabled={isApproving}
> >
@@ -1058,7 +1065,7 @@ export default function ANHReportPage() {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="w-full flex items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2 border-[#2a3038] bg-[#0f1318] text-[#9aa8ba] hover:bg-[#15191e] hover:text-white"
onClick={() => selectedReport && handleDownload(selectedReport)} onClick={() => selectedReport && handleDownload(selectedReport)}
> >
<Download className="h-4 w-4" /> Descargar JSON para revisión <Download className="h-4 w-4" /> Descargar JSON para revisión
@@ -1066,7 +1073,7 @@ export default function ANHReportPage() {
</div> </div>
) : ( ) : (
<Button <Button
className="w-full flex items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2 bg-[#ff7a1a] text-[#090b0f] hover:bg-[#ff8d3d]"
onClick={() => selectedReport && handleDownload(selectedReport)} onClick={() => selectedReport && handleDownload(selectedReport)}
> >
<Download className="h-4 w-4" /> Descargar JSON Final <Download className="h-4 w-4" /> Descargar JSON Final
@@ -1081,7 +1088,7 @@ export default function ANHReportPage() {
</div> </div>
<Dialog open={isJsonDialogOpen} onOpenChange={setIsJsonDialogOpen}> <Dialog open={isJsonDialogOpen} onOpenChange={setIsJsonDialogOpen}>
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col gap-4 border-border bg-card/95 p-5 backdrop-blur md:p-6"> <DialogContent className="flex h-[90vh] max-w-[95vw] flex-col gap-4 border-[#2a3038] bg-[#15191e]/95 p-5 text-white backdrop-blur md:p-6">
<DialogHeader className="pr-8"> <DialogHeader className="pr-8">
<DialogTitle className="flex items-center gap-2 text-xl"> <DialogTitle className="flex items-center gap-2 text-xl">
<FileJson className="h-5 w-5 text-primary" /> Revisión completa del JSON ANH <FileJson className="h-5 w-5 text-primary" /> Revisión completa del JSON ANH
@@ -1091,7 +1098,7 @@ export default function ANHReportPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 rounded-xl border border-border bg-background p-4"> <div className="min-h-0 flex-1 rounded-xl border border-[#2a3038] bg-[#090b0f] p-4">
{isPreviewLoading ? ( {isPreviewLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
@@ -1110,7 +1117,7 @@ export default function ANHReportPage() {
)} )}
</div> </div>
<div className="flex flex-col gap-2 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 border-t border-[#2a3038] pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Usa esta vista para revisar el JSON completo con tipografía legible antes de aprobar o descargar. Usa esta vista para revisar el JSON completo con tipografía legible antes de aprobar o descargar.
</p> </p>
@@ -1118,18 +1125,18 @@ export default function ANHReportPage() {
<div className="flex gap-2"> <div className="flex gap-2">
{isEditing ? ( {isEditing ? (
<> <>
<Button variant="outline" onClick={() => setIsEditing(false)} disabled={isSavingEdit}>Cancelar</Button> <Button variant="outline" className="border-[#2a3038] bg-[#0f1318] text-[#9aa8ba] hover:bg-[#15191e] hover:text-white" onClick={() => setIsEditing(false)} disabled={isSavingEdit}>Cancelar</Button>
<Button onClick={handleSaveEdit} disabled={isSavingEdit}> <Button className="bg-[#ff7a1a] text-[#090b0f] hover:bg-[#ff8d3d]" onClick={handleSaveEdit} disabled={isSavingEdit}>
{isSavingEdit ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />} {isSavingEdit ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Guardar Cambios Guardar Cambios
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Button variant="outline" onClick={handleStartEdit}> <Button variant="outline" className="border-[#2a3038] bg-[#0f1318] text-[#9aa8ba] hover:bg-[#15191e] hover:text-white" onClick={handleStartEdit}>
<Edit2 className="mr-2 h-4 w-4" /> Editar JSON <Edit2 className="mr-2 h-4 w-4" /> Editar JSON
</Button> </Button>
<Button onClick={handleApprove} disabled={isApproving}> <Button className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleApprove} disabled={isApproving}>
{isApproving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />} {isApproving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
Aprobar reporte Aprobar reporte
</Button> </Button>

View File

@@ -0,0 +1,140 @@
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import {
ShieldCheck, Clock, Info, Lock, UserPlus, LogIn,
LogOut, AlertTriangle, UserCog, FolderOpen
} from 'lucide-react'
import { cn } from '@/lib/utils/cn'
const SAMPLE_AUDIT_LOGS = [
{ id: '1', timestamp: '2025-01-15 08:32:11', user: 'admin@omnioil.app', action: 'LOGIN_SUCCESS', resource: 'AUTH', ip: '181.55.12.34', status: 'SUCCESS' },
{ id: '2', timestamp: '2025-01-15 09:14:05', user: 'admin@omnioil.app', action: 'CREATE_USER', resource: 'USER:ops@petrocol.com', ip: '181.55.12.34', status: 'SUCCESS' },
{ id: '3', timestamp: '2025-01-15 10:02:44', user: 'admin@omnioil.app', action: 'CREATE_PROJECT', resource: 'PROJECT:Campo Norte Alpha', ip: '181.55.12.34', status: 'SUCCESS' },
{ id: '4', timestamp: '2025-01-15 11:47:23', user: 'unknown', action: 'LOGIN_FAILURE', resource: 'AUTH', ip: '45.33.99.201', status: 'FAILURE' },
{ id: '5', timestamp: '2025-01-15 14:20:01', user: 'admin@omnioil.app', action: 'UPDATE_USER', resource: 'USER:ops@petrocol.com', ip: '181.55.12.34', status: 'SUCCESS' },
]
type ActionIconKey = 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'CREATE_USER' | 'CREATE_PROJECT' | 'UPDATE_USER'
const ACTION_ICONS: Record<ActionIconKey, React.ElementType> = {
LOGIN_SUCCESS: LogIn,
LOGIN_FAILURE: AlertTriangle,
CREATE_USER: UserPlus,
CREATE_PROJECT: FolderOpen,
UPDATE_USER: UserCog,
}
export default function AuditPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<ShieldCheck className="h-5 w-5 text-primary" />
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">Centro de Auditoria</h1>
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs">
Proximamente
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-0.5">Trazabilidad ISO 27001 · Registro inmutable de eventos</p>
</div>
</div>
<Card className="border-blue-500/20 bg-blue-950/10">
<CardContent className="p-4 flex items-start gap-3">
<div className="p-2 rounded-md bg-blue-500/10 shrink-0">
<Info className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium">Registros de Auditoria Fase de Desarrollo</p>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Los registros se almacenan en PostgreSQL bajo el estandar ISO 27001.
Para consultar los registros completos, utiliza el API Explorer.
</p>
<div className="flex items-center gap-1.5 mt-2">
<Lock className="h-3 w-3 text-emerald-400" />
<span className="text-xs text-emerald-400">Cumplimiento ANH Resolucion 0651 · ISO 27001</span>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
{[
{ label: 'ISO 27001', color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
{ label: 'GDPR Ready', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
{ label: 'ANH Res. 0651', color: 'bg-primary/20 text-primary border-primary/30' },
{ label: 'Trazabilidad Completa', color: 'bg-violet-500/20 text-violet-400 border-violet-500/30' },
].map(({ label, color }) => (
<Badge key={label} className={cn('text-xs border', color)}>
{label}
</Badge>
))}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-muted-foreground">Muestra de Registros (Datos de Demostracion)</p>
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs">DEMO</Badge>
</div>
<Card className="border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-secondary/30">
{['Timestamp', 'Usuario', 'Accion', 'Recurso', 'IP', 'Estado'].map((col) => (
<th key={col} className="text-left px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{SAMPLE_AUDIT_LOGS.map((log) => {
const ActionIcon = ACTION_ICONS[log.action as ActionIconKey] ?? Clock
const isFailure = log.status === 'FAILURE'
return (
<tr key={log.id} className="hover:bg-secondary/20 transition-colors">
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-muted-foreground/50 shrink-0" />
<span className="font-mono text-xs text-muted-foreground">{log.timestamp}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-xs font-medium">{log.user}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<ActionIcon className={cn('h-3.5 w-3.5 shrink-0', isFailure ? 'text-red-400' : 'text-primary')} />
<span className={cn('font-mono text-xs', isFailure ? 'text-red-400' : 'text-primary')}>{log.action}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground font-mono">{log.resource}</span>
</td>
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">{log.ip}</span>
</td>
<td className="px-4 py-3">
<Badge className={cn(
'text-xs border-0',
isFailure ? 'bg-red-500/20 text-red-400' : 'bg-emerald-500/20 text-emerald-400'
)}>
{log.status}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<p className="text-xs text-muted-foreground mt-2 text-center">
Los registros en tiempo real estaran disponibles en la proxima version del API.
</p>
</div>
</div>
)
}

View File

@@ -5,6 +5,8 @@ export interface BillingUsage {
project_name: string project_name: string
tier: string tier: string
subscription_status: string subscription_status: string
hf_variables: number
lf_variables: number
active_tags: number active_tags: number
tag_limit: number tag_limit: number
price_per_tag: number price_per_tag: number
@@ -16,6 +18,44 @@ export interface BillingUsage {
period_start: string period_start: string
period_end: string period_end: string
last_snapshot: string | null last_snapshot: string | null
commercial_profile: CommercialProfileBillingSummary | null
formula: OperativeFormulaSummary | null
calculation_lines: BillingCalculationLine[]
}
export interface CommercialProfileBillingSummary {
id: string
status: string
plan: 'free' | 'operative' | 'enterprise' | string
payment_method: string | null
payment_terms_days: number | null
credit_limit_cop: number | null
tg_limit: number | null
effective_from: string
profile_complete: boolean
missing_fields: string[]
}
export interface OperativeFormulaSummary {
hf_tags: number
lf_tags: number
equivalent_tg: number
pbase_cop: number
k: number
annual_cop: number
monthly_cop: number
currency: string
rounding_policy: string
effective_from: string
effective_hf_price_cop?: number
effective_lf_price_cop?: number
}
export interface BillingCalculationLine {
kind: string
label: string
amount_cop: number
source: string | null
} }
export interface Invoice { export interface Invoice {

View File

@@ -0,0 +1,314 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BillingPage from './BillingPage'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getBillingUsage, getInvoices, type BillingUsage, type Invoice } from '../api/billing-api'
vi.mock('../api/billing-api', () => ({
getBillingUsage: vi.fn(),
getInvoices: vi.fn(),
}))
const COP = (amount: number) =>
new Intl.NumberFormat('es-CO', {
style: 'currency',
currency: 'COP',
maximumFractionDigits: 0,
}).format(amount)
const usage: BillingUsage = {
project_id: 'project-1',
project_name: 'Campo Norte',
tier: 'standard',
subscription_status: 'active',
hf_variables: 8,
lf_variables: 4,
active_tags: 12,
tag_limit: 100,
price_per_tag: 110000,
min_annual_fee: 0,
subtotal_annual: 1320000,
minimum_applied: false,
total_annual_cop: 1320000,
total_monthly_cop: 110000,
period_start: '2026-01-01T00:00:00.000Z',
period_end: '2026-12-31T00:00:00.000Z',
last_snapshot: null,
commercial_profile: {
id: 'profile-1',
status: 'active',
plan: 'operative',
payment_method: 'wire_transfer',
payment_terms_days: 30,
credit_limit_cop: 50000000,
tg_limit: 120,
effective_from: '2026-01-01',
profile_complete: true,
missing_fields: [],
},
formula: {
hf_tags: 8,
lf_tags: 4,
equivalent_tg: 9.6,
pbase_cop: 110000,
k: 0.1,
annual_cop: 840946,
monthly_cop: 70079,
currency: 'COP',
rounding_policy: 'nearest peso',
effective_from: '2026-01-01',
effective_hf_price_cop: 87600,
effective_lf_price_cop: 35040,
},
calculation_lines: [
{
kind: 'operative_formula',
label: 'V = HF + (LF * 0.4); annual = V * Pbase * V^-k',
amount_cop: 840946,
source: 'billing_rules',
},
],
}
const invoice: Invoice = {
id: 'invoice-1',
project_id: 'project-1',
invoice_number: 'FAC-2026-001',
period_start: '2026-01-01T00:00:00.000Z',
period_end: '2026-01-31T00:00:00.000Z',
active_tags: 9,
unit_price: 110000,
subtotal: 990000,
minimum_applied: false,
total_cop: 990000,
status: 'sent',
due_date: null,
paid_at: null,
notes: null,
created_at: '2026-02-01T00:00:00.000Z',
}
function resetAuthStore() {
useAuthStore.setState(useAuthStore.getInitialState(), true)
useAuthStore.persist.clearStorage()
}
function seedAdminSession() {
useAuthStore.getState().setSession({
accessToken: 'access-token',
refreshToken: null,
role: 'admin',
email: 'admin@omnioil.app',
projects: [{ id: 'project-1', name: 'Campo Norte' }],
})
useAuthStore.getState().setActiveProject('project-1')
}
async function flushEffects() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
await Promise.resolve()
})
}
describe('BillingPage current usage split', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
let queryClient: QueryClient
beforeEach(() => {
resetAuthStore()
seedAdminSession()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
vi.mocked(getBillingUsage).mockResolvedValue(usage)
vi.mocked(getInvoices).mockResolvedValue([invoice])
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => {
root.unmount()
})
queryClient.clear()
resetAuthStore()
vi.clearAllMocks()
container.remove()
})
async function renderBillingPage() {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<BillingPage />
</QueryClientProvider>
)
})
await flushEffects()
}
it('renders Operative formula values returned by the backend', async () => {
await renderBillingPage()
expect(getBillingUsage).toHaveBeenCalledWith('project-1')
expect(container.textContent).toContain('Plan Operative')
expect(container.textContent).toContain('HF tags')
expect(container.textContent).toContain('Alta frecuencia en fórmula backend8')
expect(container.textContent).toContain('LF tags')
expect(container.textContent).toContain('Baja frecuencia con factor 0.4 TG4')
expect(container.textContent).toContain('TG equivalente 9,6')
expect(container.textContent).toContain(`Pbase ${COP(110000)}`)
expect(container.textContent).toContain('k 0,1')
expect(container.textContent).toContain('V^-k')
expect(container.textContent).toContain(COP(840946))
expect(container.textContent).toContain(COP(70079))
expect(container.textContent).toContain(COP(87600))
expect(container.textContent).toContain(COP(35040))
expect(container.textContent).not.toContain('25 tags')
})
it('renders Free as non-collecting with limits and no fake annual or monthly charge', async () => {
vi.mocked(getBillingUsage).mockResolvedValue({
...usage,
tier: 'free',
total_annual_cop: 0,
total_monthly_cop: 0,
subtotal_annual: 0,
price_per_tag: 0,
commercial_profile: {
...usage.commercial_profile!,
plan: 'free',
payment_method: 'none',
payment_terms_days: 0,
credit_limit_cop: 0,
tg_limit: 25,
},
formula: null,
calculation_lines: [
{
kind: 'free_non_collecting',
label: 'Free plan does not collect payment',
amount_cop: 0,
source: 'customer_commercial_profiles',
},
],
})
await renderBillingPage()
expect(container.textContent).toContain('Plan Free')
expect(container.textContent).toContain('Sin cobro real')
expect(container.textContent).toContain('No se recauda pago para este perfil comercial.')
expect(container.textContent).toContain('TG límite25')
expect(container.textContent).toContain('active')
expect(container.textContent).toContain('Vigente desde1/1/2026')
expect(container.textContent).not.toContain(`12 tags × ${COP(110000)} / tag / año`)
})
it('renders Enterprise configured custom billing terms and override source', async () => {
vi.mocked(getBillingUsage).mockResolvedValue({
...usage,
tier: 'enterprise',
total_annual_cop: 48000000,
total_monthly_cop: 4000000,
subtotal_annual: 48000000,
price_per_tag: 0,
commercial_profile: {
...usage.commercial_profile!,
plan: 'enterprise',
payment_method: 'purchase_order',
payment_terms_days: 45,
credit_limit_cop: 90000000,
tg_limit: 500,
},
formula: null,
calculation_lines: [
{
kind: 'enterprise_override',
label: 'Enterprise override pricing',
amount_cop: 48000000,
source: 'Contrato marco 2026 aprobado por gerencia',
},
],
})
await renderBillingPage()
expect(container.textContent).toContain('Plan Enterprise')
expect(container.textContent).toContain('Términos configurados')
expect(container.textContent).toContain('45 días')
expect(container.textContent).toContain(COP(90000000))
expect(container.textContent).toContain(COP(48000000))
expect(container.textContent).toContain(COP(4000000))
expect(container.textContent).toContain('Contrato marco 2026 aprobado por gerencia')
})
it('does not render obsolete active-tags times Pbase messaging for profile-backed billing', async () => {
await renderBillingPage()
expect(container.textContent).not.toContain(`12 tags × ${COP(110000)} / tag / año`)
expect(container.textContent).not.toContain('Total tags activos facturables')
expect(container.textContent).toContain('Fuente backend')
})
it('keeps historical invoice rows total-only and does not fabricate HF/LF history', async () => {
await renderBillingPage()
const invoiceHistory = container.querySelector('[data-testid="invoice-history"]')
expect(invoiceHistory?.textContent).toContain('FAC-2026-001')
expect(invoiceHistory?.textContent).toContain('9')
expect(invoiceHistory?.textContent).toContain(COP(990000))
expect(invoiceHistory?.textContent).not.toContain('Alta frecuencia')
expect(invoiceHistory?.textContent).not.toContain('Baja frecuencia')
expect(invoiceHistory?.textContent).not.toContain('HF')
expect(invoiceHistory?.textContent).not.toContain('LF')
})
it('renders zero current usage from API data without demo or fallback counts', async () => {
vi.mocked(getBillingUsage).mockResolvedValue({
...usage,
hf_variables: 0,
lf_variables: 0,
active_tags: 0,
subtotal_annual: 0,
total_annual_cop: 0,
total_monthly_cop: 0,
formula: {
...usage.formula!,
hf_tags: 0,
lf_tags: 0,
equivalent_tg: 0,
annual_cop: 0,
monthly_cop: 0,
effective_hf_price_cop: 0,
effective_lf_price_cop: 0,
},
})
await renderBillingPage()
expect(container.textContent).toContain('Alta frecuencia (HF)0')
expect(container.textContent).toContain('Baja frecuencia (LF)0')
expect(container.textContent).toContain('Total tags activos0')
expect(container.textContent).toContain('Alta frecuencia en fórmula backend0')
expect(container.textContent).toContain('Baja frecuencia con factor 0.4 TG0')
expect(container.textContent).not.toContain('25 tags')
})
it('shows a usage error state without stale or fabricated counts when usage fails', async () => {
vi.mocked(getBillingUsage).mockRejectedValue(new Error('usage unavailable'))
await renderBillingPage()
expect(container.textContent).toContain('No se pudo cargar el uso actual de facturación.')
expect(container.textContent).not.toContain('Alta frecuencia')
expect(container.textContent).not.toContain('Total tags activos')
expect(container.textContent).not.toContain('25 tags')
})
})

View File

@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getBillingUsage, getInvoices, type Invoice } from '../api/billing-api' import { getBillingUsage, getInvoices, type BillingUsage, type Invoice } from '../api/billing-api'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CreditCard, Tag, Calendar, Mail } from 'lucide-react' import { Activity, AlertTriangle, Calendar, CreditCard, Gauge, Mail, ReceiptText, Tag, Wallet } from 'lucide-react'
const COP = (amount: number) => const COP = (amount: number) =>
new Intl.NumberFormat('es-CO', { new Intl.NumberFormat('es-CO', {
@@ -16,6 +16,8 @@ const tierLabel: Record<string, string> = {
starter: 'Starter', starter: 'Starter',
standard: 'Standard', standard: 'Standard',
enterprise: 'Enterprise', enterprise: 'Enterprise',
free: 'Free',
operative: 'Operative',
} }
const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = { const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
@@ -25,22 +27,40 @@ const tierVariant: Record<string, 'default' | 'secondary' | 'destructive'> = {
} }
const invoiceStatusConfig: Record<string, { label: string; className: string }> = { const invoiceStatusConfig: Record<string, { label: string; className: string }> = {
pending: { label: 'Pendiente', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' }, pending: { label: 'Pendiente', className: 'bg-orange-500/15 text-orange-300 border-orange-400/40' },
sent: { label: 'Enviada', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' }, sent: { label: 'Enviada', className: 'bg-sky-500/15 text-sky-300 border-sky-400/35' },
paid: { label: 'Pagada', className: 'bg-green-500/20 text-green-400 border-green-500/30' }, paid: { label: 'Pagada', className: 'bg-emerald-500/15 text-emerald-300 border-emerald-400/35' },
overdue: { label: 'Vencida', className: 'bg-red-500/20 text-red-400 border-red-500/30' }, overdue: { label: 'Vencida', className: 'bg-red-500/15 text-red-300 border-red-400/35' },
cancelled: { label: 'Cancelada', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' }, cancelled: { label: 'Cancelada', className: 'bg-slate-500/15 text-slate-300 border-slate-400/30' },
} }
function InvoiceStatusBadge({ status }: { status: Invoice['status'] }) { function InvoiceStatusBadge({ status }: { status: Invoice['status'] }) {
const cfg = invoiceStatusConfig[status] ?? invoiceStatusConfig.pending! const cfg = invoiceStatusConfig[status] ?? invoiceStatusConfig.pending!
return ( return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${cfg.className}`}> <span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${cfg.className}`}>
{cfg.label} {cfg.label}
</span> </span>
) )
} }
const shellCard = 'border-[#2a3038] bg-[#11151a] shadow-[0_0_0_1px_rgba(255,255,255,0.02)]'
const panelCard = 'border-[#2d333c] bg-[#12161c] shadow-[0_18px_50px_rgba(0,0,0,0.18)]'
const mutedLabel = 'text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
const tableHeader = 'px-4 py-3 text-left text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
const tableCell = 'px-4 py-3 text-sm text-slate-300'
const planLabel = (plan: string) => tierLabel[plan] ?? plan
const formatDate = (value: string) => new Date(value).toLocaleDateString('es-CO', { timeZone: 'UTC' })
const formatNumber = (value: number) => new Intl.NumberFormat('es-CO', { maximumFractionDigits: 2 }).format(value)
function profilePlan(usage: BillingUsage) {
return usage.commercial_profile?.plan ?? usage.tier
}
function billingSource(usage: BillingUsage) {
return usage.commercial_profile ? 'Fuente backend · perfil comercial' : 'Legado · suscripción sin perfil comercial'
}
export default function BillingPage() { export default function BillingPage() {
const activeProjectId = useAuthStore((s) => s.activeProjectId) const activeProjectId = useAuthStore((s) => s.activeProjectId)
@@ -72,195 +92,398 @@ export default function BillingPage() {
) )
} }
if (usageQuery.isError) {
return (
<div className="flex items-center justify-center h-64 px-6">
<div className="max-w-md rounded border border-red-500/30 bg-red-500/10 p-4 text-center">
<p className="text-red-300 font-semibold">No se pudo cargar el uso actual de facturación.</p>
<p className="text-gray-400 text-sm mt-1">
Intenta nuevamente más tarde; no se muestran conteos hasta recibir datos reales del API.
</p>
</div>
</div>
)
}
const usage = usageQuery.data const usage = usageQuery.data
if (!usage) return null if (!usage) return null
const tagPercent = usage.tag_limit > 0 ? (usage.active_tags / usage.tag_limit) * 100 : 0 const tagPercent = usage.tag_limit > 0 ? (usage.active_tags / usage.tag_limit) * 100 : 0
const barColor = const barColor =
usage.tag_limit === 0 ? 'bg-gray-500' : tagPercent > 80 ? 'bg-amber-400' : 'bg-green-500' usage.tag_limit === 0 ? 'bg-slate-600' : tagPercent > 80 ? 'bg-orange-400' : 'bg-emerald-400'
const formattedPeriodStart = new Date(usage.period_start).toLocaleDateString('es-CO')
const formattedPeriodEnd = new Date(usage.period_end).toLocaleDateString('es-CO')
const invoices = invoicesQuery.data ?? []
const commercialProfile = usage.commercial_profile
const formula = usage.formula
const plan = profilePlan(usage)
const planName = planLabel(plan)
const primaryLine = usage.calculation_lines[0]
const effectiveHfPrice = formula?.effective_hf_price_cop ?? usage.price_per_tag
const effectiveLfPrice = formula?.effective_lf_price_cop ?? null
const displayedAnnualCop = plan === 'operative' && formula ? formula.annual_cop : usage.total_annual_cop
const displayedMonthlyCop = plan === 'operative' && formula ? formula.monthly_cop : usage.total_monthly_cop
return ( return (
<div className="space-y-6 p-6"> <div className="min-h-full bg-[#080a0d] px-4 py-5 text-slate-100 sm:px-6 lg:px-8">
<div> <div className="mx-auto max-w-[1720px] space-y-5">
<h1 className="text-2xl font-black text-white">Facturación & Plan</h1> <div className="flex flex-col gap-3 border-b border-[#242a32] pb-4 lg:flex-row lg:items-end lg:justify-between">
<p className="text-gray-400 text-sm mt-1">{usage.project_name}</p>
</div>
{/* Plan summary */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* Plan card */}
<Card className="bg-gray-900 border-gray-700 col-span-1">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Plan Activo
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Badge variant={tierVariant[usage.tier] ?? 'secondary'} className="text-sm px-3 py-1">
{tierLabel[usage.tier] ?? usage.tier}
</Badge>
<span className={`text-xs px-2 py-0.5 rounded border ${
usage.subscription_status === 'active'
? 'bg-green-500/20 text-green-400 border-green-500/30'
: usage.subscription_status === 'trial'
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
: 'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{usage.subscription_status}
</span>
</div>
</CardContent>
</Card>
{/* Tags card */}
<Card className="bg-gray-900 border-gray-700 col-span-1">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
<Tag className="h-4 w-4" />
Tags Activos
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-black text-white">
{usage.active_tags}
{usage.tag_limit > 0 && (
<span className="text-sm text-gray-400 font-normal"> / {usage.tag_limit}</span>
)}
</p>
{usage.tag_limit > 0 && (
<div className="mt-2 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(tagPercent, 100)}%` }}
/>
</div>
)}
</CardContent>
</Card>
{/* Period card */}
<Card className="bg-gray-900 border-gray-700 col-span-1">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-gray-400 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Período de Facturación
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-300">
Desde{' '}
<span className="text-white font-semibold">
{new Date(usage.period_start).toLocaleDateString('es-CO')}
</span>
</p>
<p className="text-xs text-gray-300 mt-1">
Hasta{' '}
<span className="text-white font-semibold">
{new Date(usage.period_end).toLocaleDateString('es-CO')}
</span>
</p>
</CardContent>
</Card>
</div>
{/* Cost breakdown */}
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="text-white font-bold">Resumen de Costos</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">
{usage.active_tags} tags × {COP(usage.price_per_tag)} / tag / año
</span>
<span className="text-white font-semibold">{COP(usage.subtotal_annual)}</span>
</div>
{usage.minimum_applied && (
<div className="flex justify-between items-center text-sm bg-amber-500/10 rounded px-3 py-2 border border-amber-500/20">
<span className="text-amber-400">Cargo mínimo aplicado</span>
<span className="text-amber-400 font-semibold">{COP(usage.min_annual_fee)} / año</span>
</div>
)}
<div className="border-t border-gray-700 pt-3 flex justify-between items-center">
<span className="text-white font-bold">Total anual</span>
<span className="text-2xl font-black text-amber-400">{COP(usage.total_annual_cop)}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Estimado mensual</span>
<span className="text-gray-300 font-semibold">{COP(usage.total_monthly_cop)} / mes</span>
</div>
</CardContent>
</Card>
{/* Invoice history */}
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="text-white font-bold">Historial de Facturas</CardTitle>
</CardHeader>
<CardContent>
{invoicesQuery.isLoading ? (
<div className="py-8 flex justify-center">
<div className="animate-spin h-6 w-6 border-2 border-amber-400 border-t-transparent rounded-full" />
</div>
) : invoicesQuery.data?.length === 0 ? (
<p className="text-gray-500 text-sm text-center py-6">Sin facturas generadas aún.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-400 border-b border-gray-700">
<th className="text-left py-2 pr-4">Factura</th>
<th className="text-left py-2 pr-4">Período</th>
<th className="text-right py-2 pr-4">Tags</th>
<th className="text-right py-2 pr-4">Total</th>
<th className="text-left py-2 pr-4">Estado</th>
<th className="text-left py-2">Fecha</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{invoicesQuery.data?.map((inv) => (
<tr key={inv.id} className="text-gray-300 hover:bg-gray-800/50">
<td className="py-2 pr-4 font-mono text-xs">{inv.invoice_number}</td>
<td className="py-2 pr-4 text-xs">
{new Date(inv.period_start).toLocaleDateString('es-CO')} {' '}
{new Date(inv.period_end).toLocaleDateString('es-CO')}
</td>
<td className="py-2 pr-4 text-right">{inv.active_tags}</td>
<td className="py-2 pr-4 text-right font-semibold text-white">
{COP(inv.total_cop)}
</td>
<td className="py-2 pr-4">
<InvoiceStatusBadge status={inv.status} />
</td>
<td className="py-2 text-xs text-gray-400">
{new Date(inv.created_at).toLocaleDateString('es-CO')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Contact card */}
<Card className="bg-gray-900 border-gray-700">
<CardContent className="pt-4 flex items-center gap-3">
<Mail className="h-5 w-5 text-amber-400 flex-shrink-0" />
<div> <div>
<p className="text-sm text-gray-400">Para facturación y preguntas sobre tu plan:</p> <div className="mb-2 inline-flex rounded-md border border-orange-500/35 bg-orange-500/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-orange-300">
<a Consola de facturación
href="mailto:facturacion@omnioil.app" </div>
className="text-amber-400 hover:text-amber-300 font-semibold text-sm" <h1 className="text-2xl font-semibold tracking-[-0.02em] text-white sm:text-3xl">Facturación</h1>
> <p className="mt-1 text-sm text-slate-400">Consumo y facturación por proyecto · {usage.project_name}</p>
facturacion@omnioil.app
</a>
</div> </div>
</CardContent> <div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
</Card> <Badge variant={tierVariant[plan] ?? 'secondary'} className="border border-orange-400/25 bg-orange-500/10 px-3 py-1 text-orange-200">
Plan {planName}
</Badge>
<span className="rounded-md border border-emerald-400/25 bg-emerald-500/10 px-3 py-1 font-semibold uppercase tracking-[0.14em] text-emerald-300">
{usage.subscription_status}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<Card className={shellCard}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className={mutedLabel}>{plan === 'free' ? 'Sin cobro real' : 'Costo anual actual'}</p>
<p className="mt-3 text-2xl font-semibold leading-none tracking-[-0.025em] text-white">{COP(displayedAnnualCop)}</p>
<p className="mt-2 text-xs text-slate-500">{billingSource(usage)}</p>
</div>
<span className="rounded-lg border border-orange-400/20 bg-orange-500/10 p-2 text-orange-300">
<ReceiptText className="h-4 w-4" />
</span>
</div>
</CardContent>
</Card>
<Card className={shellCard}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className={mutedLabel}>Estimado mensual</p>
<p className="mt-3 text-2xl font-semibold leading-none tracking-[-0.025em] text-white">{COP(displayedMonthlyCop)}</p>
<p className="mt-2 text-xs text-slate-500">{plan === 'free' ? 'No se recauda pago' : 'Valor devuelto por backend'}</p>
</div>
<span className="rounded-lg border border-sky-400/20 bg-sky-500/10 p-2 text-sky-300">
<Calendar className="h-4 w-4" />
</span>
</div>
</CardContent>
</Card>
<Card className={shellCard}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className={mutedLabel}>Tags activos</p>
<p className="mt-3 text-2xl font-semibold leading-none tracking-[-0.025em] text-white">
{usage.active_tags}
{usage.tag_limit > 0 && <span className="text-sm font-semibold text-slate-500"> / {usage.tag_limit}</span>}
</p>
<p className="mt-2 text-xs text-slate-500">{usage.hf_variables} HF · {usage.lf_variables} LF</p>
</div>
<span className="rounded-lg border border-emerald-400/20 bg-emerald-500/10 p-2 text-emerald-300">
<Tag className="h-4 w-4" />
</span>
</div>
</CardContent>
</Card>
<Card className={shellCard}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className={mutedLabel}>Periodo vigente</p>
<p className="mt-3 text-lg font-semibold leading-none tracking-[-0.01em] text-white">{formattedPeriodEnd}</p>
<p className="mt-2 text-xs text-slate-500">Desde {formattedPeriodStart}</p>
</div>
<span className="rounded-lg border border-amber-400/20 bg-amber-500/10 p-2 text-amber-300">
<Activity className="h-4 w-4" />
</span>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
<div className="space-y-5">
<Card className={panelCard}>
<CardHeader className="border-b border-[#252b33] px-4 py-3">
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
<ReceiptText className="h-4 w-4" />
</span>
<span>
Desglose comercial
<span className="mt-0.5 block text-xs font-medium text-slate-500">{usage.project_name} · Plan {planName} · {billingSource(usage)}</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{!commercialProfile && (
<div className="m-4 flex gap-3 rounded-lg border border-amber-400/30 bg-amber-500/10 p-3 text-sm text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-semibold">Facturación legacy sin perfil comercial</p>
<p className="mt-1 text-xs text-amber-100/75">Este proyecto no tiene perfil comercial activo; los importes vienen de la suscripción heredada.</p>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] border-collapse text-sm">
<thead className="bg-[#0d1116]">
<tr className="border-b border-[#2b313a]">
<th className={tableHeader}>Concepto</th>
<th className={tableHeader}>Detalle</th>
<th className={`${tableHeader} text-right`}>Cant.</th>
<th className={`${tableHeader} text-right`}>Unitario</th>
<th className={`${tableHeader} text-right`}>Total</th>
</tr>
</thead>
<tbody className="divide-y divide-[#20262e]">
{commercialProfile && plan === 'free' && (
<tr className="hover:bg-white/[0.025]">
<td className={`${tableCell} font-semibold text-white`}>Sin cobro real</td>
<td className={tableCell}>No se recauda pago para este perfil comercial.</td>
<td className={`${tableCell} text-right tabular-nums`}>{usage.active_tags}</td>
<td className={`${tableCell} text-right font-mono text-slate-500`}>{COP(0)}</td>
<td className={`${tableCell} text-right font-mono font-semibold text-emerald-300`}>{COP(0)}</td>
</tr>
)}
{commercialProfile && plan === 'operative' && formula && (
<>
<tr className="hover:bg-white/[0.025]">
<td className={`${tableCell} font-semibold text-white`}>HF tags</td>
<td className={tableCell}>Alta frecuencia en fórmula backend</td>
<td className={`${tableCell} text-right tabular-nums`}>{formula.hf_tags}</td>
<td className={`${tableCell} text-right font-mono text-slate-300`}>{COP(effectiveHfPrice)}</td>
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>{COP(effectiveHfPrice * formula.hf_tags)}</td>
</tr>
<tr className="hover:bg-white/[0.025]">
<td className={`${tableCell} font-semibold text-white`}>LF tags</td>
<td className={tableCell}>Baja frecuencia con factor 0.4 TG</td>
<td className={`${tableCell} text-right tabular-nums`}>{formula.lf_tags}</td>
<td className={`${tableCell} text-right font-mono text-slate-300`}>{effectiveLfPrice == null ? 'No informado' : COP(effectiveLfPrice)}</td>
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>Incluido en V</td>
</tr>
<tr className="bg-orange-500/[0.035]">
<td className={`${tableCell} font-semibold text-orange-100`}>V^-k</td>
<td className={tableCell}>V = HF + (LF × 0.4) · Pbase · k</td>
<td className={`${tableCell} text-right tabular-nums`}>TG equivalente {formatNumber(formula.equivalent_tg)}</td>
<td className={`${tableCell} text-right font-mono text-orange-200`}>Pbase {COP(formula.pbase_cop)} · k {formatNumber(formula.k)}</td>
<td className={`${tableCell} text-right font-mono font-semibold text-orange-200`}>{COP(formula.annual_cop)}</td>
</tr>
</>
)}
{commercialProfile && plan === 'enterprise' && (
<tr className="hover:bg-white/[0.025]">
<td className={`${tableCell} font-semibold text-white`}>Términos configurados</td>
<td className={tableCell}>{primaryLine?.source ?? 'Override Enterprise aprobado'}</td>
<td className={`${tableCell} text-right tabular-nums`}>1</td>
<td className={`${tableCell} text-right font-mono text-slate-300`}>{COP(usage.total_annual_cop)}</td>
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>{COP(usage.total_annual_cop)}</td>
</tr>
)}
{!commercialProfile && (
<tr className="hover:bg-white/[0.025]">
<td className={`${tableCell} font-semibold text-white`}>Suscripción legacy</td>
<td className={tableCell}>Importe anual heredado de backend</td>
<td className={`${tableCell} text-right tabular-nums`}>{usage.active_tags}</td>
<td className={`${tableCell} text-right font-mono text-slate-300`}>{COP(usage.price_per_tag)}</td>
<td className={`${tableCell} text-right font-mono font-semibold text-white`}>{COP(usage.subtotal_annual)}</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="border-t border-[#2b313a] bg-[#0d1116] px-4 py-4">
<div className="ml-auto w-full max-w-md space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Subtotal anual</span>
<span className="font-mono font-semibold text-white">{COP(displayedAnnualCop)}</span>
</div>
{usage.minimum_applied && (
<div className="flex items-center justify-between text-sm">
<span className="text-orange-300">Mínimo aplicado</span>
<span className="font-mono font-semibold text-orange-300">{COP(usage.min_annual_fee)}</span>
</div>
)}
<div className="flex items-center justify-between border-t border-[#303741] pt-3">
<span className="text-base font-semibold text-white">Total anual</span>
<span className="font-mono text-2xl font-semibold tracking-[-0.025em] text-orange-400">{COP(displayedAnnualCop)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Estimado mensual</span>
<span className="font-mono font-semibold text-slate-300">{COP(displayedMonthlyCop)} / mes</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className={panelCard}>
<CardHeader className="border-b border-[#252b33] px-4 py-3">
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
<CreditCard className="h-4 w-4" />
</span>
<span>
Historial de facturas
<span className="mt-0.5 block text-xs font-medium text-slate-500">{invoices.length} factura(s) · {usage.project_name}</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0" data-testid="invoice-history">
{invoicesQuery.isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-400 border-t-transparent" />
</div>
) : invoices.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-500">Sin facturas generadas aún.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-[#0d1116]">
<tr className="border-b border-[#2b313a]">
<th className={tableHeader}>Factura</th>
<th className={tableHeader}>Periodo</th>
<th className={`${tableHeader} text-right`}>Tags</th>
<th className={`${tableHeader} text-right`}>Total</th>
<th className={tableHeader}>Estado</th>
<th className={tableHeader}>Emitida</th>
</tr>
</thead>
<tbody className="divide-y divide-[#20262e]">
{invoices.map((inv) => (
<tr key={inv.id} className="text-slate-300 transition-colors hover:bg-white/[0.025]">
<td className="px-4 py-3 font-mono text-xs font-semibold text-white">{inv.invoice_number}</td>
<td className="px-4 py-3 text-xs text-slate-400">
{new Date(inv.period_start).toLocaleDateString('es-CO')} - {new Date(inv.period_end).toLocaleDateString('es-CO')}
</td>
<td className="px-4 py-3 text-right font-mono text-sm text-slate-200">{inv.active_tags}</td>
<td className="px-4 py-3 text-right font-mono text-sm font-semibold text-white">{COP(inv.total_cop)}</td>
<td className="px-4 py-3"><InvoiceStatusBadge status={inv.status} /></td>
<td className="px-4 py-3 text-xs text-slate-500">{new Date(inv.created_at).toLocaleDateString('es-CO')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
<aside className="space-y-5">
<Card className={panelCard}>
<CardHeader className="border-b border-[#252b33] px-4 py-3">
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
<Gauge className="h-4 w-4" />
</span>
Uso actual
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-2 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Alta frecuencia (HF)</p>
<p className="mt-2 text-2xl font-semibold text-white">{usage.hf_variables}</p>
</div>
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-2 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Baja frecuencia (LF)</p>
<p className="mt-2 text-2xl font-semibold text-white">{usage.lf_variables}</p>
</div>
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-2 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Total tags activos</p>
<p className="mt-2 text-2xl font-semibold text-white">{usage.active_tags}</p>
</div>
</div>
{usage.tag_limit > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400">
<span>Capacidad del plan</span>
<span className="font-mono text-white">{usage.active_tags} / {usage.tag_limit}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#242a31]">
<div
className={`h-full rounded-full shadow-[0_0_18px_rgba(251,146,60,0.35)] transition-all ${barColor}`}
style={{ width: `${Math.min(tagPercent, 100)}%` }}
/>
</div>
</div>
)}
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] px-3 py-2 text-xs text-slate-400">
Renovación y corte del periodo: <span className="font-semibold text-white">{formattedPeriodEnd}</span>
</div>
</CardContent>
</Card>
<Card className={panelCard}>
<CardHeader className="border-b border-[#252b33] px-4 py-3">
<CardTitle className="flex items-center gap-3 text-sm font-semibold text-white">
<span className="rounded-md border border-orange-400/25 bg-orange-500/10 p-1.5 text-orange-300">
<Wallet className="h-4 w-4" />
</span>
Método de pago
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4">
{commercialProfile && (
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Perfil comercial</p>
<span className="rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-300">
{commercialProfile.status}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<p className="text-slate-500">Términos</p>
<p className="mt-1 font-semibold text-white">{commercialProfile.payment_terms_days ?? 0} días</p>
</div>
<div>
<p className="text-slate-500">TG límite</p>
<p className="mt-1 font-semibold text-white">{commercialProfile.tg_limit ?? 'No definido'}</p>
</div>
<div>
<p className="text-slate-500">Crédito</p>
<p className="mt-1 font-semibold text-white">{COP(commercialProfile.credit_limit_cop ?? 0)}</p>
</div>
<div>
<p className="text-slate-500">Vigente desde</p>
<p className="mt-1 font-semibold text-white">{formatDate(commercialProfile.effective_from)}</p>
</div>
</div>
{!commercialProfile.profile_complete && (
<p className="mt-3 rounded-md border border-amber-400/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-100">
Perfil incompleto: {commercialProfile.missing_fields.join(', ') || 'faltan campos requeridos'}.
</p>
)}
</div>
)}
<div className="rounded-lg border border-[#2a3038] bg-[#0d1116] p-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Gestión comercial</p>
<p className="mt-2 text-sm font-semibold text-white">Método gestionado por facturación OmniOil</p>
<a
href="mailto:facturacion@omnioil.app"
className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-orange-300 hover:text-orange-200"
>
<Mail className="h-4 w-4" />
facturacion@omnioil.app
</a>
</div>
</CardContent>
</Card>
</aside>
</div>
</div>
</div> </div>
) )
} }

Some files were not shown because too many files have changed in this diff Show More