258 Commits

Author SHA1 Message Date
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
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
ac411b1893 Merge pull request 'fix(mqtt): soportar payloads grandes de deploy' (#11) from fix/mqtt-large-deploy-payloads into main
Reviewed-on: #11
2026-06-25 23:18:13 +00:00
henryor
d70efd6b5e fix(mqtt): support larger deploy payloads 2026-06-25 18:08:56 -05:00
49431911fc Merge pull request 'fix(edge): recuperar builds de instalador detenidos' (#10) from fix/edge-installer-recovery into main
Reviewed-on: #10
2026-06-25 23:08:01 +00:00
henryor
f28cb4dad7 fix(edge): recover stalled installer builds 2026-06-25 18:00:22 -05:00
baa906001e Merge pull request 'feat(admin): rediseñar workspace de detalle de proyecto' (#9) from feat/admin-project-detail-workspace into main
Reviewed-on: #9
2026-06-25 22:58:58 +00:00
henryor
f9151dd9ba test(admin): normalize router navigation paths 2026-06-25 17:48:15 -05:00
henryor
f009c43bd2 fix(admin): refresh sidebar after project creation 2026-06-25 17:48:15 -05:00
henryor
113be509ee fix(admin): refresh sidebar after project changes 2026-06-25 17:48:14 -05:00
henryor
439b80dca8 feat(admin): restyle project detail workspace 2026-06-25 17:48:14 -05:00
51e27e49f4 Merge pull request 'feat(admin): renovar vista de proyectos y shell visual' (#8) from feat/admin-projects-overview-visuals into main
Reviewed-on: #8
2026-06-25 22:46:53 +00:00
henryor
948b372f40 style(admin): polish projects overview cards 2026-06-25 17:35:18 -05:00
henryor
3c6cc42216 feat(admin): compact projects overview cards 2026-06-25 17:35:18 -05:00
henryor
675b3033a4 feat(admin): align shell visual system 2026-06-25 17:35:18 -05:00
henryor
b5357a3d0c feat(admin): restyle projects list 2026-06-25 17:35:18 -05:00
761a4ab5a4 Merge pull request 'feat(admin): rediseñar panel SCADA de proyecto' (#7) from feat/admin-project-dashboard-shell into main
Reviewed-on: #7
2026-06-25 22:27:15 +00:00
henryor
89907fbb58 test(admin): use typed project dashboard fixtures 2026-06-25 17:19:45 -05:00
henryor
3dfe4c5659 feat(admin): restyle project agent logs 2026-06-25 17:19:45 -05:00
henryor
741034ac39 feat(admin): restyle project action console 2026-06-25 17:19:44 -05:00
henryor
8cfd7a085b feat(admin): restyle project agent health 2026-06-25 17:19:44 -05:00
henryor
88dc61512f feat(admin): restyle project well cards 2026-06-25 17:19:44 -05:00
henryor
18d8ffa375 feat(admin): add project scada dashboard shell 2026-06-25 17:19:44 -05:00
henryor
c1254566c1 refactor(admin): extract project dashboard layout 2026-06-25 17:19:44 -05:00
d5c768d9c3 Merge pull request 'feat(admin): add SCADA dashboard foundation' (#6) from feat/admin-dashboard-scada-foundation into main
Reviewed-on: #6
2026-06-25 22:16:06 +00:00
b7f1873504 Merge pull request 'chore(repo): ignore local generated artifacts' (#5) from chore/repo-ignore-local-generated-artifacts into main
Reviewed-on: #5
2026-06-25 20:42:55 +00:00
henryor
fac9ab3fe0 feat(admin): add dashboard scada empty states 2026-06-25 15:30:48 -05:00
henryor
3417d8afaf feat(admin): restyle telemetry chart panels 2026-06-25 15:30:48 -05:00
henryor
e6f5412ca2 feat(admin): restyle dashboard kpi cards 2026-06-25 15:30:48 -05:00
henryor
29e89a0a41 feat(admin): add scada dashboard header 2026-06-25 15:30:48 -05:00
henryor
3caf9ba42b refactor(admin): extract dashboard foundation components 2026-06-25 15:30:48 -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
106 changed files with 27277 additions and 1953 deletions

View File

@@ -21,8 +21,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
# --- Stage 2: Base builder --- # --- Stage 2: Base builder ---
FROM deps AS builder FROM deps AS builder
# Copy the rest of the source code # Copiar únicamente las carpetas de origen del frontend para no invalidar por cambios en backend/Rust
COPY . . COPY services/landing services/landing
COPY services/frontend-admin services/frontend-admin
COPY services/frontend-console services/frontend-console
COPY services/frontend-pwa services/frontend-pwa
# --- Stage 3: Landing build --- # --- Stage 3: Landing build ---
FROM builder AS builder-landing FROM builder AS builder-landing

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 \

View File

@@ -66,7 +66,7 @@ services:
- MQTT_USER=${MQTT_USER} - MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD} - MQTT_PASSWORD=${MQTT_PASSWORD}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL} - MINIO_ENDPOINT_URL=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
@@ -269,7 +269,7 @@ services:
- MQTT_PORT=1883 - MQTT_PORT=1883
- MQTT_USER=${MQTT_USER} - MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD} - MQTT_PASSWORD=${MQTT_PASSWORD}
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL} - MINIO_ENDPOINT_URL=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- AWS_REGION=${AWS_REGION} - AWS_REGION=${AWS_REGION}

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

0
infrastructure/mosquitto/entrypoint.sh Executable file → Normal file
View File

View File

@@ -44,6 +44,9 @@ persistence true
persistence_location /mosquitto/data persistence_location /mosquitto/data
# Límite de tamaño de paquetes MQTT # Límite de tamaño de paquetes MQTT
# 256KB: suficiente para payloads de provisioning comprimidos y telemetría # 2MB: suficiente para ProjectConfig sin comprimir durante deploys locales/grandes
# (rumqttc también está configurado con 256KB) # y payloads de telemetría de simulador con cientos de variables por dispositivo.
message_size_limit 262144 # Mosquitto 2.x anuncia `max_packet_size` a clientes MQTT v5; mantener ambos
# evita que clientes rumqttc sigan viendo el límite default de 256KB.
max_packet_size 2097152
message_size_limit 2097152

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

@@ -14,6 +14,114 @@ use serde::{Deserialize, Serialize};
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::Claims;
use crate::AppState;
use rumqttc::QoS;
use crate::handlers::update_info::fetch_sha256_internal;
#[derive(Debug, Deserialize)]
pub struct OtaRequest {
/// Desired version tag, e.g. "v1.2.3". Defaults to AGENT_LATEST_VERSION.
pub version: Option<String>,
/// "msi" | "exe" (default: "msi").
pub asset_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct OtaResponse {
pub message: String,
pub agent_id: Uuid,
pub target_version: String,
}
/// POST /api/edge/agents/:agent_id/ota
///
/// Generates the download URL + SHA-256 internally and sends a single MQTT command
/// to the agent. The agent needs no HTTP credentials — the URL is embedded in the command.
pub async fn trigger_ota_update(
State(state): State<AppState>,
claims: Claims,
Path(agent_id): Path<Uuid>,
Json(req): Json<OtaRequest>,
) -> Result<(StatusCode, Json<OtaResponse>), crate::errors::AppError> {
if claims.role != "admin" {
return Err(crate::errors::AppError::Forbidden(
"Se requiere rol admin para disparar actualizaciones OTA".to_string(),
));
}
// 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"
)
.bind(agent_id)
.fetch_optional(&state.pool)
.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
let target_version = match req.version.as_deref() {
Some(v) => v.trim_start_matches('v').to_string(),
None => std::env::var("AGENT_LATEST_VERSION").map_err(|_| {
crate::errors::AppError::Internal(anyhow::anyhow!(
"AGENT_LATEST_VERSION not set and no version provided in request"
))
})?,
};
let asset_type = req.asset_type.as_deref().unwrap_or("msi").to_string();
// 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.
let api_base = std::env::var("API_BASE_URL")
.unwrap_or_else(|_| "https://api.omnioil.com".to_string());
let download_url = format!(
"{}/api/edge/download-agent?version={}&asset_type={}",
api_base.trim_end_matches('/'),
target_version,
asset_type
);
// Fetch SHA-256 from MinIO internally so the agent can verify the binary.
let sha256 = fetch_sha256_internal(&target_version, &asset_type).await;
let topic = format!("omnioil/commands/{}", project_id);
let command_payload = serde_json::json!({
"command": "ota_update",
"target_version": target_version,
"download_url": download_url,
"sha256": sha256,
"asset_type": asset_type,
"issued_by": claims.email,
"issued_at": chrono::Utc::now().to_rfc3339(),
});
let payload_bytes = serde_json::to_vec(&command_payload)
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?;
state.mqtt_client
.publish(&topic, QoS::AtLeastOnce, false, payload_bytes)
.await
.map_err(|e| crate::errors::AppError::Internal(anyhow::anyhow!("MQTT publish failed: {}", e)))?;
tracing::info!(
"📡 OTA command published to {} by {} (target: v{}, sha256: {})",
topic,
claims.email,
target_version,
sha256.as_deref().unwrap_or("not available")
);
Ok((
StatusCode::ACCEPTED,
Json(OtaResponse {
message: "OTA update command dispatched successfully".to_string(),
agent_id,
target_version,
}),
))
}
use crate::handlers::update_info::fetch_sha256_internal; use crate::handlers::update_info::fetch_sha256_internal;

View File

@@ -13,6 +13,44 @@ use chrono;
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono;
async fn mark_build_dispatch_failed(
pool: &PgPool,
project_id: Uuid,
deployment_id: Uuid,
error_message: &str,
) {
if let Err(e) = sqlx::query(
r#"UPDATE edge_deployments
SET status = 'FAILED', error_message = $1
WHERE id = $2"#,
)
.bind(error_message)
.bind(deployment_id)
.execute(pool)
.await
{
tracing::error!(
"Failed to mark deployment {} as FAILED after build dispatch error: {}",
deployment_id,
e
);
}
if let Err(e) =
sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
.bind(project_id)
.execute(pool)
.await
{
tracing::error!(
"Failed to mark project {} installer as FAILED after build dispatch error: {}",
project_id,
e
);
}
}
/// GET /api/edge/projects/:project_id/deployments /// GET /api/edge/projects/:project_id/deployments
/// ///
@@ -113,10 +151,11 @@ pub async fn trigger_build(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI // 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1") sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
.bind(project_id) .bind(project_id)
.execute(&pool) .execute(&pool)
.await; .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// ─── Dispatch Build Request via MQTT ────────────────────────────── // ─── Dispatch Build Request via MQTT ──────────────────────────────
let event = shared_lib::mqtt_messages::ProjectCreatedEvent { let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
@@ -127,23 +166,34 @@ pub async fn trigger_build(
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
}; };
if let Ok(payload) = serde_json::to_string(&event) { let payload = serde_json::to_string(&event).map_err(|e| {
let mqtt_clone = mqtt.clone(); let message = format!("Failed to serialize ProjectCreatedEvent: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, message)
})?;
tracing::info!( tracing::info!(
"📡 Enviando señal de construcción para despliegue {}: {}", "📡 Enviando señal de construcción para despliegue {}: {}",
deployment.id, deployment.id,
payload payload
); );
tokio::spawn(async move { if let Err(e) = mqtt
let _ = mqtt_clone
.publish( .publish(
"omnioil/events/project_created", "omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce, rumqttc::QoS::AtLeastOnce,
false, false,
payload, payload,
) )
.await; .await
}); {
let message = format!("MQTT publish failed for installer build request: {}", e);
tracing::error!(
"{} (project={}, deployment={})",
message,
project_id,
deployment.id
);
mark_build_dispatch_failed(&pool, project_id, deployment.id, &message).await;
return Err((StatusCode::INTERNAL_SERVER_ERROR, message));
} }
Ok((StatusCode::ACCEPTED, Json(deployment))) Ok((StatusCode::ACCEPTED, Json(deployment)))

View File

@@ -11,6 +11,7 @@ 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;
use chrono;
/// GET /api/edge/projects — Listar todos los proyectos. /// GET /api/edge/projects — Listar todos los proyectos.
pub async fn list_projects( pub async fn list_projects(
@@ -1090,19 +1091,22 @@ pub async fn rebuild_installer(
) )
})?; })?;
mqtt.publish( if let Err(e) = mqtt
.publish(
"omnioil/events/project_created", "omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce, rumqttc::QoS::AtLeastOnce,
false, false,
payload, payload,
) )
.await .await
.map_err(|e| { {
( let message = format!("MQTT Publish failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR, let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
format!("MQTT Publish failed: {}", e), .bind(id)
) .execute(&pool)
})?; .await;
return Err((StatusCode::INTERNAL_SERVER_ERROR, message));
}
tracing::info!( tracing::info!(
"🛠️ Reconstrucción de instalador solicitada para proyecto: {}", "🛠️ Reconstrucción de instalador solicitada para proyecto: {}",

View File

@@ -118,9 +118,9 @@ async fn main() {
let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4()); let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4());
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port); let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30)); mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
// Aumentar el límite de paquetes: por defecto rumqttc usa 10KB, insuficiente // Large simulator imports can produce ProjectConfig deploy requests above
// para payloads de provisioning y telemetría comprimida (256KB es seguro). // 400KB before the build-orchestrator compresses the agent deploy signal.
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024); mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) { if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
mqtt_options.set_credentials(u, p); mqtt_options.set_credentials(u, p);

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

@@ -8,6 +8,9 @@ use sqlx::postgres::PgPoolOptions;
use std::env; use std::env;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const PROJECT_CREATED_TOPIC: &str = "omnioil/events/project_created";
const DEPLOY_CONTROL_TOPIC: &str = "omnioil/control/deploy/#";
/// Estado compartido del orquestador. /// Estado compartido del orquestador.
#[derive(Clone)] #[derive(Clone)]
pub struct OrchestratorState { pub struct OrchestratorState {
@@ -15,6 +18,104 @@ pub struct OrchestratorState {
pub mqtt_client: rumqttc::AsyncClient, pub mqtt_client: rumqttc::AsyncClient,
} }
#[derive(sqlx::FromRow)]
struct PendingInstallerBuild {
id: Uuid,
name: String,
client: String,
deployment_id: Option<Uuid>,
installer_status: Option<String>,
deployment_status: Option<String>,
}
async fn subscribe_topics(client: &rumqttc::AsyncClient) -> Result<(), rumqttc::ClientError> {
client
.subscribe(PROJECT_CREATED_TOPIC, rumqttc::QoS::AtLeastOnce)
.await?;
client
.subscribe(DEPLOY_CONTROL_TOPIC, rumqttc::QoS::AtLeastOnce)
.await?;
tracing::info!(
"✅ MQTT subscriptions active: {}, {}",
PROJECT_CREATED_TOPIC,
DEPLOY_CONTROL_TOPIC
);
Ok(())
}
async fn recover_pending_installer_builds(
state: OrchestratorState,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pending = sqlx::query_as::<_, PendingInstallerBuild>(
r#"
SELECT
p.id,
p.name,
p.client,
p.installer_status,
latest.id AS deployment_id,
latest.status AS deployment_status
FROM edge_projects p
LEFT JOIN LATERAL (
SELECT id, status
FROM edge_deployments
WHERE project_id = p.id
ORDER BY created_at DESC
LIMIT 1
) latest ON true
WHERE p.installer_status = 'BUILDING'
ORDER BY p.created_at ASC
"#,
)
.fetch_all(&state.db_pool)
.await?;
if pending.is_empty() {
tracing::info!("✅ No pending installer builds found during startup recovery");
return Ok(());
}
tracing::warn!(
"🔁 Recovering {} pending installer build(s) after orchestrator startup",
pending.len()
);
for project in pending {
tracing::warn!(
"🔁 Recovering installer build for project {} ({}) deployment={:?} project_status={:?} deployment_status={:?}",
project.id,
project.name,
project.deployment_id,
project.installer_status,
project.deployment_status
);
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
project_id: project.id,
deployment_id: project.deployment_id,
name: project.name,
client: project.client,
timestamp: chrono::Utc::now(),
};
if let Err(e) = installer_worker::handle_project_created(
state.db_pool.clone(),
state.mqtt_client.clone(),
event,
)
.await
{
tracing::error!(
"❌ Startup recovery failed for installer build {}: {}",
project.id,
e
);
}
}
Ok(())
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok(); dotenv().ok();
@@ -68,8 +169,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4()); let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port); let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30)); mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
// 256KB: suficiente para binarios de instalador por partes y payloads comprimidos // Control deploy requests are uncompressed ProjectConfig JSON from backend-api;
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024); // large simulator imports can exceed the previous 256KB packet limit.
mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
if let (Ok(u), Ok(p)) = (env::var("MQTT_USER"), env::var("MQTT_PASSWORD")) { if let (Ok(u), Ok(p)) = (env::var("MQTT_USER"), env::var("MQTT_PASSWORD")) {
mqtt_options.set_credentials(u, p); mqtt_options.set_credentials(u, p);
@@ -77,13 +179,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10); let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
// Subscribe to Domain Events and Deployment Requests // Subscribe to Domain Events and Deployment Requests. The event loop also
mqtt_client // restores these subscriptions on every CONNACK because a broker reconnect
.subscribe("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce) // can drop session subscriptions.
.await?; subscribe_topics(&mqtt_client).await?;
mqtt_client
.subscribe("omnioil/control/deploy/#", rumqttc::QoS::AtLeastOnce)
.await?;
// Spawn MQTT event loop // Spawn MQTT event loop
let state_worker = OrchestratorState { let state_worker = OrchestratorState {
@@ -94,13 +193,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match eventloop.poll().await { match eventloop.poll().await {
Ok(item) => { Ok(item) => match item {
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = item { rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_)) => {
tracing::info!("🔌 MQTT connection acknowledged; restoring subscriptions");
if let Err(e) = subscribe_topics(&state_worker.mqtt_client).await {
tracing::error!("Failed to restore MQTT subscriptions: {}", e);
}
}
rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) => {
let topic = publish.topic.clone(); let topic = publish.topic.clone();
let payload = publish.payload.clone(); let payload = publish.payload.clone();
let state = state_worker.clone(); let state = state_worker.clone();
if topic == "omnioil/events/project_created" { if topic == PROJECT_CREATED_TOPIC {
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::<
@@ -154,7 +259,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
} }
} _ => {}
},
Err(e) => { Err(e) => {
tracing::error!("MQTT event loop error: {}", e); tracing::error!("MQTT event loop error: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
@@ -163,6 +269,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
let recovery_state = OrchestratorState {
db_pool: pool.clone(),
mqtt_client: mqtt_client.clone(),
};
tokio::spawn(async move {
if let Err(e) = recover_pending_installer_builds(recovery_state).await {
tracing::error!("Startup installer recovery failed: {}", e);
}
});
// Subir binario genérico de Linux a MinIO si existe en el contenedor // Subir binario genérico de Linux a MinIO si existe en el contenedor
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = installer_worker::upload_generic_linux_binary().await { if let Err(e) = installer_worker::upload_generic_linux_binary().await {

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

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

View File

@@ -297,8 +297,9 @@ impl AgentConfig {
mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token); mqtt_opts.set_credentials(&prov_username, &stub.provisioning_token);
mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15)); mqtt_opts.set_keep_alive(std::time::Duration::from_secs(15));
mqtt_opts.set_clean_session(true); mqtt_opts.set_clean_session(true);
// 256KB: suficiente para la respuesta de provisioning cifrada + comprimida // 2MB: keep provisioning/deploy paths aligned with the broker and
mqtt_opts.set_max_packet_size(256 * 1024, 256 * 1024); // internal services for large local simulator configurations.
mqtt_opts.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
// Aplicar el transporte correcto según el stub. // Aplicar el transporte correcto según el stub.
if stub.use_ws { if stub.use_ws {

View File

@@ -71,12 +71,16 @@ 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, CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
CRYPT_INTEGER_BLOB,
}; };
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues. // LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
@@ -84,31 +88,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 +126,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 +167,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

@@ -61,12 +61,42 @@ 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 device = device_cfg.device;
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
}
let mqtt = self.mqtt_client.clone(); let mqtt = self.mqtt_client.clone();
let topic = telemetry_topic.clone(); let topic = telemetry_topic.clone();
let storage_clone = self.storage.clone(); let storage_clone = self.storage.clone();
let project_id = self.project_id; let project_id = self.project_id;
let device = device_cfg.device;
let reporter = log_reporter.clone();
// Map variables // Map variables
let mut variables = HashMap::new(); let mut variables = HashMap::new();
@@ -174,13 +204,14 @@ impl DataCollectorManager {
); );
Box::new(SqlDbDriver::new(conn_str)) Box::new(SqlDbDriver::new(conn_str))
} }
_ => { other => {
let msg = format!( // Este branch no debería alcanzarse porque filtramos arriba,
"Unsupported protocol for device {}: {:?}", // pero lo dejamos como safety net.
device.name, device.protocol tracing::error!(
"❌ Protocol {:?} reached driver init for device {} — this is a bug.",
other,
device.name
); );
tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
return; return;
} }
}; };
@@ -275,7 +306,7 @@ impl DataCollectorManager {
device.name, e device.name, e
); );
let _ = reporter.error("MQTT", &msg).await; let _ = reporter.error("MQTT", &msg).await;
let _ = storage_clone.save(&topic, &payload_str); let _ = storage_clone.save(&topic, &payload_str).await;
} else { } else {
let _ = reporter let _ = reporter
.info( .info(

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};
@@ -122,7 +123,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 +133,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 {

View File

@@ -16,17 +16,44 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
loop { loop {
#[cfg(windows)] #[cfg(windows)]
{ {
let server = ServerOptions::new() // Crear instancia del pipe. Usamos first_pipe_instance(false) para permitir
.first_pipe_instance(true) // que el SO limpie la instancia anterior si quedó en estado inconsistente
.create(pipe_name)?; // (ej. el tray icon se desconectó abruptamente). Esto evita que un error
// en la creación del pipe mate silenciosamente toda la tarea IPC.
let server = match ServerOptions::new()
.first_pipe_instance(false)
.create(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);
} }
}); });
} }
@@ -55,7 +82,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

@@ -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};
@@ -258,13 +259,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 +277,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();
@@ -294,6 +351,15 @@ pub async fn run_agent(
)); ));
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 {

View File

@@ -139,8 +139,9 @@ pub async fn setup_mqtt_client(
mqtt_options.set_keep_alive(Duration::from_secs(30)); mqtt_options.set_keep_alive(Duration::from_secs(30));
mqtt_options.set_clean_session(true); mqtt_options.set_clean_session(true);
// 256KB: suficiente para cualquier mensaje de telemetría comprimida // 2MB: supports large deploy signals and high-cardinality telemetry payloads
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024); // during local simulator runs with hundreds of variables per device.
mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
// Last Will Testament: si el agente se desconecta inesperadamente, // Last Will Testament: si el agente se desconecta inesperadamente,
// el broker publica automáticamente el estado "offline" de forma inmediata. // el broker publica automáticamente el estado "offline" de forma inmediata.

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,7 +18,7 @@ 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 tokio::sync::Mutex;
const SALT: &[u8; 15] = b"omnioil_db_salt"; const SALT: &[u8; 15] = b"omnioil_db_salt";
@@ -80,7 +85,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 +96,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 +104,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 +124,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 +152,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 +176,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

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

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

@@ -6,11 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AdminLayout from './AdminLayout' import AdminLayout from './AdminLayout'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getAlarms } from '@/features/admin/alerts/api/alerts-api' import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({ vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
getAlarms: vi.fn(async () => []), getAlarms: vi.fn(async () => []),
})) }))
vi.mock('@/features/admin/projects/api/projects-api', () => ({
listProjects: vi.fn(async () => []),
getProjectFullConfig: vi.fn(async () => ({ project: { id: 'project-1', name: 'Bloque Norte' }, assets: [] })),
}))
async function flushQueries() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
function resetAuthStore() { function resetAuthStore() {
useAuthStore.setState(useAuthStore.getInitialState(), true) useAuthStore.setState(useAuthStore.getInitialState(), true)
useAuthStore.persist.clearStorage() useAuthStore.persist.clearStorage()
@@ -42,6 +57,14 @@ describe('AdminLayout visual shell', () => {
vi.clearAllMocks() vi.clearAllMocks()
resetAuthStore() resetAuthStore()
seedAdminSession() seedAdminSession()
vi.mocked(listProjects).mockResolvedValue([
{ id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
{ id: 'project-2', name: 'Bloque Sur', client: 'Cliente', operator_name: 'Operador', contract_number: '002', is_active: true, created_at: '2026-06-22T00:00:00Z' },
])
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [],
})
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
container = document.createElement('div') container = document.createElement('div')
document.body.appendChild(container) document.body.appendChild(container)
@@ -156,6 +179,32 @@ describe('AdminLayout visual shell', () => {
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull() expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
}) })
it('matches query-specific realtime navigation items by search params', async () => {
await act(async () => {
root?.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/realtime?view=trends']}>
<Routes>
<Route path="/app/admin/*" element={<AdminLayout />}>
<Route path="realtime" element={<div>Realtime operativo</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
const topbar = container?.querySelector('[aria-label="Barra superior del dashboard"]')
const variablesLink = container?.querySelector<HTMLAnchorElement>('a[href="/app/admin/realtime?view=variables"]')
const trendsLink = container?.querySelector<HTMLAnchorElement>('a[href="/app/admin/realtime?view=trends"]')
expect(topbar?.textContent).toContain('Tendencias')
expect(variablesLink?.getAttribute('aria-current')).toBeNull()
expect(trendsLink?.getAttribute('aria-current')).toBe('page')
expect(variablesLink?.className).not.toContain('font-semibold')
expect(trendsLink?.className).toContain('font-semibold')
})
it('exposes a desktop sidebar toggle before the current location', async () => { it('exposes a desktop sidebar toggle before the current location', async () => {
await act(async () => { await act(async () => {
root?.render( root?.render(
@@ -212,6 +261,79 @@ describe('AdminLayout visual shell', () => {
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull() expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
}) })
it('renders real project wells under the Pozos sidebar group with active alarm badges', async () => {
vi.mocked(getAlarms).mockResolvedValue([
{
id: 'alarm-1',
project_id: 'project-1',
asset_id: 'well-1',
variable_alias: 'presion',
alarm_type: 'ABOVE_MAX',
acknowledged: false,
timestamp: '2026-06-22T00:00:00Z',
},
])
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [
{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
devices: [],
},
{
asset: {
id: 'tank-1',
project_id: 'project-1',
code: 'TK-01',
name: 'Tanque Norte',
asset_type: 'TANQUE',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
devices: [],
},
],
})
await act(async () => {
root?.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/*" element={<AdminLayout />}>
<Route path="wells/:wellId" element={<div>Dashboard del pozo</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
await vi.waitFor(() => {
expect(container?.textContent).toContain('Pozo Norte')
})
expect(container?.textContent).toContain('Pozos')
expect(container?.textContent).not.toContain('Tanque Norte')
expect(container?.querySelector('a[href="/app/admin/wells/well-1"]')?.className).toContain('bg-[#24282e]')
expect(container?.textContent).toContain('1')
})
it('restores the persisted collapsed desktop preference on render', async () => { it('restores the persisted collapsed desktop preference on render', async () => {
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true') localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom' import { Outlet, NavLink, Link, useNavigate, useLocation, type Location } from 'react-router-dom'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
@@ -12,13 +12,18 @@ import {
LogOut, LogOut,
FolderOpen, FolderOpen,
Activity, Activity,
Table2,
LineChart,
Users, Users,
Menu, Menu,
X, X,
CreditCard CreditCard,
ChevronDown,
Gauge,
} from 'lucide-react' } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils/cn' import { cn } from '@/lib/utils/cn'
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed' const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
@@ -26,6 +31,19 @@ function isPlatformGlobalRole(role?: string | null) {
return role === 'platform_admin' || role === 'platform_operator' return role === 'platform_admin' || role === 'platform_operator'
} }
function isNavItemActive(to: string, location: Location) {
const [path, query] = to.split('?')
const pathMatches = location.pathname === path || location.pathname.startsWith(`${path}/`)
if (!pathMatches) return false
if (!query) return true
const expectedParams = new URLSearchParams(query)
const currentParams = new URLSearchParams(location.search)
return Array.from(expectedParams.entries()).every(([key, value]) => currentParams.get(key) === value)
}
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
@@ -50,7 +68,9 @@ export const navSections: NavSection[] = [
label: 'Operaciones', label: 'Operaciones',
items: [ items: [
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] }, { to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] },
{ to: '/app/admin/realtime', label: 'Telemetria', icon: Activity, roles: ['admin', 'superadmin'] }, { to: '/app/admin/realtime?view=variables', label: 'Variables', icon: Table2, roles: ['admin', 'superadmin'] },
{ to: '/app/admin/realtime?view=trends', label: 'Tendencias', icon: LineChart, roles: ['admin', 'superadmin'] },
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, roles: ['admin', 'superadmin'] },
], ],
}, },
{ {
@@ -58,7 +78,6 @@ export const navSections: NavSection[] = [
items: [ items: [
{ to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] }, { to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] },
{ to: '/app/admin/settings', label: 'Ajustes', icon: Settings, roles: ['admin', 'superadmin'] }, { to: '/app/admin/settings', label: 'Ajustes', icon: Settings, roles: ['admin', 'superadmin'] },
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, roles: ['admin', 'superadmin'] },
{ to: '/app/admin/billing', label: 'Facturacion', icon: CreditCard, roles: ['admin', 'superadmin'] }, { to: '/app/admin/billing', label: 'Facturacion', icon: CreditCard, roles: ['admin', 'superadmin'] },
], ],
}, },
@@ -76,6 +95,8 @@ export default function AdminLayout() {
const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(() => { const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true' return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
}) })
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
const [isWellsNavOpen, setIsWellsNavOpen] = useState(true)
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId) const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
@@ -85,7 +106,25 @@ export default function AdminLayout() {
refetchInterval: 60000, refetchInterval: 60000,
enabled: canFetchAlarms, enabled: canFetchAlarms,
}) })
const sidebarProjectsQuery = useQuery({
queryKey: ['admin-sidebar-projects'],
queryFn: listProjects,
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
staleTime: 60000,
})
const sidebarProjects = sidebarProjectsQuery.data ?? []
const sidebarProjectId = activeProjectId ?? (sidebarProjects.length === 1 ? sidebarProjects[0]?.id : null)
const sidebarProjectConfigQuery = useQuery({
queryKey: ['admin-sidebar-project-config', sidebarProjectId],
queryFn: () => getProjectFullConfig(sidebarProjectId!),
enabled: !!accessToken && !!sidebarProjectId && ['admin', 'superadmin', 'auditor'].includes(user?.role || ''),
staleTime: 60000,
})
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0 const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
const sidebarWells = sidebarProjectConfigQuery.data?.assets
.map(({ asset }) => asset)
.filter((asset) => asset.asset_type === 'POZO') ?? []
const handleLogout = () => { const handleLogout = () => {
logout() logout()
@@ -110,17 +149,24 @@ export default function AdminLayout() {
})).filter(section => section.items.length > 0) })).filter(section => section.items.length > 0)
const activeNavItem = filteredSections const activeNavItem = filteredSections
.flatMap((section) => section.items) .flatMap((section) => section.items)
.find((item) => location.pathname === item.to || location.pathname.startsWith(`${item.to}/`)) .find((item) => isNavItemActive(item.to, location))
const currentSectionLabel = activeNavItem?.label ?? 'Administración' const isWellRouteActive = location.pathname.startsWith('/app/admin/wells/')
const currentSectionLabel = activeNavItem?.label ?? (isWellRouteActive ? 'Pozos' : 'Administración')
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U' const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
const alertsButtonLabel = unacknowledgedCount > 0 const alertsButtonLabel = unacknowledgedCount > 0
? `Ver ${unacknowledgedCount} alertas pendientes` ? `Ver ${unacknowledgedCount} alertas pendientes`
: 'Ver alertas' : 'Ver alertas'
const getProjectAlarmCount = (projectId: string) => {
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
}
const getWellAlarmCount = (wellId: string) => {
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === wellId).length
}
return ( return (
<div className="flex h-screen bg-background text-foreground overflow-hidden"> <div className="flex h-screen overflow-hidden bg-[#090b0f] text-foreground">
{/* Mobile top bar */} {/* Mobile top bar */}
<header className="md:hidden fixed top-0 w-full h-14 px-4 flex items-center justify-between border-b border-white/10 bg-card/95 backdrop-blur-md z-30"> <header className="md:hidden fixed top-0 w-full h-14 px-4 flex items-center justify-between border-b border-[#232831] bg-[#0b0f14]/95 backdrop-blur-md z-30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <img
src="/omnioil-logo-transparente.svg" src="/omnioil-logo-transparente.svg"
@@ -160,15 +206,15 @@ export default function AdminLayout() {
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={cn( className={cn(
"fixed inset-y-0 left-0 z-50 md:z-20 flex w-56 shrink-0 flex-col", "fixed inset-y-0 left-0 z-50 md:z-20 flex w-[216px] shrink-0 flex-col",
"border-r border-white/10 bg-card/95 backdrop-blur-md md:relative md:translate-x-0 transition-all duration-300 ease-out", "border-r border-[#232831] bg-[#0b0f14] md:relative md:translate-x-0 transition-all duration-300 ease-out",
isDesktopSidebarCollapsed ? "md:w-20" : "md:w-56", isDesktopSidebarCollapsed ? "md:w-20" : "md:w-[216px]",
isSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
{/* Logo */} {/* Logo */}
<div className={cn( <div className={cn(
"flex h-20 shrink-0 items-center justify-between border-b border-white/10 px-4", "flex h-20 shrink-0 items-center justify-between border-b border-[#232831] px-4",
isDesktopSidebarCollapsed && "md:justify-center md:px-3" isDesktopSidebarCollapsed && "md:justify-center md:px-3"
)}> )}>
<div className={cn( <div className={cn(
@@ -192,7 +238,7 @@ export default function AdminLayout() {
)} )}
/> />
<p className={cn( <p className={cn(
"pl-0.5 text-[9px] font-semibold text-primary/75 uppercase tracking-[0.22em] transition-opacity duration-200", "pl-0.5 text-[9px] font-semibold text-primary uppercase tracking-[0.28em] transition-opacity duration-200",
isDesktopSidebarCollapsed && "md:sr-only" isDesktopSidebarCollapsed && "md:sr-only"
)}>Operación centralizada</p> )}>Operación centralizada</p>
</div> </div>
@@ -207,66 +253,205 @@ export default function AdminLayout() {
{/* Nav */} {/* Nav */}
<nav className={cn( <nav className={cn(
"flex-1 overflow-y-auto custom-sidebar-scroll py-3 px-2 space-y-4", "flex-1 overflow-y-auto custom-sidebar-scroll px-2 py-3 space-y-5",
isDesktopSidebarCollapsed && "md:px-3" isDesktopSidebarCollapsed && "md:px-3"
)}> )}>
{filteredSections.map((section) => ( {filteredSections.map((section) => (
<div key={section.label}> <div key={section.label}>
<p className={cn( <p className={cn(
"px-3 mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider", "mb-1.5 px-2 text-[10px] font-semibold text-[#8ca0bb] uppercase tracking-[0.18em]",
isDesktopSidebarCollapsed && "md:sr-only" isDesktopSidebarCollapsed && "md:sr-only"
)}> )}>
{section.label} {section.label}
</p> </p>
<div className="space-y-0.5"> <div className="space-y-0.5">
{section.items.map((item) => ( {section.items.map((item) => {
const isProjectsItem = item.to === '/app/admin/projects'
if (isProjectsItem) {
return (
<div key={item.to}>
<div className="flex items-center gap-1">
<NavLink <NavLink
key={item.to}
to={item.to} to={item.to}
title={item.label} title={item.label}
aria-label={item.label} aria-label={item.label}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'relative flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors', 'relative flex min-w-0 flex-1 items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
isDesktopSidebarCollapsed && 'md:justify-center md:px-0', isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
isActive isActive
? 'bg-primary/10 text-primary font-medium shadow-sm shadow-orange-500/10' ? 'bg-[#24282e] text-white font-semibold shadow-none [&>svg]:text-primary'
: 'text-muted-foreground hover:bg-primary/10 hover:text-foreground' : 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
) )
} }
>
<item.icon className="h-4 w-4 shrink-0" />
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
</NavLink>
{sidebarProjects.length > 0 && !isDesktopSidebarCollapsed && (
<button
type="button"
onClick={() => setIsProjectsNavOpen((open) => !open)}
className="flex h-8 w-7 shrink-0 items-center justify-center rounded-md text-[#8b98aa] transition-colors hover:bg-[#171b20] hover:text-white"
aria-label={isProjectsNavOpen ? 'Ocultar proyectos' : 'Mostrar proyectos'}
aria-expanded={isProjectsNavOpen}
>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isProjectsNavOpen && '-rotate-90')} />
</button>
)}
</div>
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
{sidebarProjects.map((project) => {
const projectAlarmCount = getProjectAlarmCount(project.id)
return (
<div key={project.id}>
<NavLink
to={`/app/admin/projects/${project.id}`}
title={project.name}
className={({ isActive }) =>
cn(
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
isActive
? 'bg-[#24282e] text-white'
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
)
}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
project.is_active ? 'bg-success' : 'bg-muted-foreground'
)}
/>
<span className="min-w-0 flex-1 truncate">{project.name}</span>
{projectAlarmCount > 0 && (
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
{projectAlarmCount}
</Badge>
)}
</NavLink>
</div>
)
})}
</div>
)}
</div>
)
}
return (
<div key={item.to}>
<Link
to={item.to}
title={item.label}
aria-label={item.label}
aria-current={isNavItemActive(item.to, location) ? 'page' : undefined}
className={cn(
'relative flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
isNavItemActive(item.to, location)
? 'bg-[#24282e] text-white font-semibold shadow-none [&>svg]:text-primary'
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
)}
> >
<item.icon className="h-4 w-4 shrink-0" /> <item.icon className="h-4 w-4 shrink-0" />
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span> <span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
{item.to.includes('/alerts') && unacknowledgedCount > 0 && ( {item.to.includes('/alerts') && unacknowledgedCount > 0 && (
<Badge className={cn( <Badge className={cn(
"ml-auto bg-destructive text-destructive-foreground text-[10px] border-0 px-1.5 min-w-[18px] text-center", "ml-auto bg-destructive text-destructive-foreground text-[10px] border-0 px-1.5 min-w-[18px] text-center shadow-sm shadow-red-500/20",
isDesktopSidebarCollapsed && "md:absolute md:right-1 md:top-1 md:ml-0 md:min-w-0 md:px-1" isDesktopSidebarCollapsed && "md:absolute md:right-1 md:top-1 md:ml-0 md:min-w-0 md:px-1"
)}> )}>
{unacknowledgedCount} {unacknowledgedCount}
</Badge> </Badge>
)} )}
</NavLink> </Link>
))}
</div> </div>
)
})}
</div>
{section.label === 'General' && sidebarWells.length > 0 && !isDesktopSidebarCollapsed && (
<div className="mt-2 space-y-0.5">
<button
type="button"
onClick={() => setIsWellsNavOpen((open) => !open)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
isWellRouteActive
? 'bg-[#24282e] text-white font-semibold [&>svg]:text-primary'
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
)}
aria-expanded={isWellsNavOpen}
aria-controls="admin-sidebar-wells"
>
<Gauge className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 text-left">Pozos</span>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isWellsNavOpen && '-rotate-90')} />
</button>
{isWellsNavOpen && (
<div id="admin-sidebar-wells" className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
{sidebarWells.map((well) => {
const wellAlarmCount = getWellAlarmCount(well.id)
const wellLabel = well.name || well.code || well.id.slice(0, 8)
return (
<NavLink
key={well.id}
to={`/app/admin/wells/${well.id}`}
title={wellLabel}
className={({ isActive }) =>
cn(
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
isActive
? 'bg-[#24282e] text-white'
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
)
}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
well.is_active ? 'bg-success' : 'bg-muted-foreground'
)}
/>
<span className="min-w-0 flex-1 truncate">{wellLabel}</span>
{wellAlarmCount > 0 && (
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
{wellAlarmCount}
</Badge>
)}
</NavLink>
)
})}
</div>
)}
</div>
)}
</div> </div>
))} ))}
</nav> </nav>
<div className="hidden md:block h-4 shrink-0 border-t border-white/10" /> <div className="hidden md:block h-4 shrink-0 border-t border-[#232831]" />
</aside> </aside>
{/* Main content */} {/* Main content */}
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-14 md:pt-0"> <main className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-14 md:pt-0">
<header <header
aria-label="Barra superior del dashboard" aria-label="Barra superior del dashboard"
className="hidden md:flex h-20 shrink-0 items-center justify-between gap-4 border-b border-white/10 bg-card/80 px-6 backdrop-blur-md" className="hidden h-20 shrink-0 items-center justify-between gap-4 border-b border-[#232831] bg-[#0b0f14]/90 px-6 backdrop-blur-md md:flex"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => setIsDesktopSidebarCollapsed((collapsed) => !collapsed)} onClick={() => setIsDesktopSidebarCollapsed((collapsed) => !collapsed)}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-background/50 text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/10 hover:text-primary" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/30 bg-primary text-primary-foreground shadow-lg shadow-orange-500/20 transition-colors hover:bg-primary/90"
aria-label={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'} aria-label={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
title={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'} title={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
aria-pressed={isDesktopSidebarCollapsed} aria-pressed={isDesktopSidebarCollapsed}
@@ -276,9 +461,9 @@ export default function AdminLayout() {
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 shadow-lg shadow-orange-500/10"> <div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 shadow-lg shadow-orange-500/10">
<img src="/icon.png" alt="" className="h-6 w-6" aria-hidden="true" /> <img src="/icon.png" alt="" className="h-6 w-6" aria-hidden="true" />
</div> </div>
<div className="min-w-0 border border-white/10 px-3 py-2 transition-colors hover:border-primary/40 hover:bg-primary/10 rounded-xl" data-testid="admin-current-location"> <div className="min-w-0 rounded-xl border border-[#2a3038] bg-[#15191e] px-3 py-2 transition-colors hover:border-primary/40 hover:bg-primary/10" data-testid="admin-current-location">
<div className="text-[10px] font-bold uppercase tracking-[0.26em] text-primary/80"> <div className="text-[10px] font-bold uppercase tracking-[0.26em] text-[#9aa8ba]">
Consola Admin CONSOLA ADMIN
</div> </div>
<h1 className="mt-0.5 truncate text-2xl font-bold tracking-tight text-foreground">{currentSectionLabel}</h1> <h1 className="mt-0.5 truncate text-2xl font-bold tracking-tight text-foreground">{currentSectionLabel}</h1>
</div> </div>
@@ -289,7 +474,7 @@ export default function AdminLayout() {
<button <button
type="button" type="button"
onClick={() => navigate('/app/admin/alerts')} onClick={() => navigate('/app/admin/alerts')}
className="relative rounded-full border border-white/10 bg-background/50 p-2.5 text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary" className="relative rounded-full border border-[#2a3038] bg-[#15191e] p-2.5 text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary"
aria-label={alertsButtonLabel} aria-label={alertsButtonLabel}
title={alertsButtonLabel} title={alertsButtonLabel}
> >
@@ -298,7 +483,7 @@ export default function AdminLayout() {
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card" /> <span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card" />
)} )}
</button> </button>
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-background/50 py-1.5 pl-1.5 pr-2 shadow-sm shadow-black/10"> <div className="flex items-center gap-3 rounded-full border border-[#2a3038] bg-[#15191e] py-1.5 pl-1.5 pr-2 shadow-sm shadow-black/10">
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-sm font-semibold text-primary"> <div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-sm font-semibold text-primary">
{userInitial} {userInitial}
</div> </div>

View File

@@ -8,7 +8,7 @@ describe('admin router navigation contract', () => {
section.items section.items
.map((item) => item.to) .map((item) => item.to)
.filter((path) => path.startsWith('/app/admin/')) .filter((path) => path.startsWith('/app/admin/'))
.map((path) => path.replace('/app/admin/', '')) .map((path) => path.replace('/app/admin/', '').split('?')[0])
) )
expect(adminChildRoutePaths).toEqual(expect.arrayContaining(navigationPaths)) expect(adminChildRoutePaths).toEqual(expect.arrayContaining(navigationPaths))

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'
@@ -17,11 +18,13 @@ const ANHReportPage = lazy(() => import('@/features/admin/anh-reports/pages/ANHR
const AlertsPage = lazy(() => import('@/features/admin/alerts/pages/AlertsPage')) const AlertsPage = lazy(() => import('@/features/admin/alerts/pages/AlertsPage'))
const SettingsPage = lazy(() => import('@/features/admin/settings/pages/SettingsPage')) const SettingsPage = lazy(() => import('@/features/admin/settings/pages/SettingsPage'))
const BillingPage = lazy(() => import('@/features/admin/billing/pages/BillingPage')) const BillingPage = lazy(() => import('@/features/admin/billing/pages/BillingPage'))
const WellDashboardPage = lazy(() => import('@/features/admin/dashboard/pages/WellDashboardPage'))
export const adminChildRoutes = [ export const adminChildRoutes = [
{ index: true, element: <Navigate to="dashboard" replace /> }, { index: true, element: <Navigate to="dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> }, { path: 'dashboard', element: <DashboardPage /> },
{ path: 'realtime', element: <DashboardPage /> }, { path: 'realtime', element: <RealtimePage /> },
{ path: 'wells/:wellId', element: <Suspense fallback={null}><WellDashboardPage /></Suspense> },
{ path: 'projects', element: <ProjectListPage /> }, { path: 'projects', element: <ProjectListPage /> },
{ path: 'projects/new', element: <CreateProjectPage /> }, { path: 'projects/new', element: <CreateProjectPage /> },
{ path: 'projects/:id', element: <ProjectDetailsPage /> }, { path: 'projects/:id', element: <ProjectDetailsPage /> },

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"flex flex-col gap-6 rounded-xl border border-white/10 bg-card/90 py-6 text-card-foreground shadow-2xl shadow-black/20 backdrop-blur-sm", "flex flex-col gap-6 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-2xl shadow-black/20 backdrop-blur-sm",
className className
)} )}
{...props} {...props}

View File

@@ -49,8 +49,8 @@ describe('admin UI primitive visual language', () => {
const button = container?.querySelector('[data-slot="button"]') const button = container?.querySelector('[data-slot="button"]')
const badge = container?.querySelector('[data-slot="badge"]') const badge = container?.querySelector('[data-slot="badge"]')
expect(card?.className).toContain('bg-card/90') expect(card?.className).toContain('bg-card')
expect(card?.className).toContain('border-white/10') expect(card?.className).toContain('border-border')
expect(button?.textContent).toBe('Guardar') expect(button?.textContent).toBe('Guardar')
expect(badge?.textContent).toBe('Activo') expect(badge?.textContent).toBe('Activo')
}) })

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">
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#2a3038] pb-3">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.22em] text-[#9aa8ba]">
<span>CONSOLA ADMIN</span>
<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 */} {/* Header */}
<Card className="relative overflow-hidden border-border bg-card/80 shadow-2xl shadow-black/20"> <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(249,115,22,0.16),transparent_34%),linear-gradient(135deg,rgba(249,115,22,0.10),transparent_32%)]" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,122,26,0.14),transparent_30%)]" />
<CardContent className="relative p-5 lg:p-6"> <div className="relative flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="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> </section>
</div>
</CardContent>
</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

@@ -12,9 +12,37 @@ interface DashboardSummaryResponse {
data: TelemetrySummary[] data: TelemetrySummary[]
} }
export interface TelemetryHistoryRecord {
time?: string
created_at?: string
timestamp?: string
asset_id?: string
data?: Record<string, unknown>
values?: Record<string, unknown>
telemetry?: Record<string, unknown>
[key: string]: unknown
}
export interface TelemetryHistoryResponse {
asset_id?: string
resolution?: string
records?: TelemetryHistoryRecord[]
count?: number
}
export async function fetchDashboardSummary(): Promise<DashboardSummaryResponse> { export async function fetchDashboardSummary(): Promise<DashboardSummaryResponse> {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
return apiClient<DashboardSummaryResponse>('/dashboard/summary', { token }) return apiClient<DashboardSummaryResponse>('/dashboard/summary', { token, cache: 'no-store' })
}
export async function fetchAssetTelemetry(assetId: string, limit: number = 100): Promise<any> {
const token = useAuthStore.getState().token
return apiClient<any>(`/telemetry/${assetId}?limit=${limit}`, { token })
}
export async function fetchAssetTelemetryHistory(assetId: string, days: number = 1, limit: number = 288): Promise<TelemetryHistoryResponse> {
const token = useAuthStore.getState().token
return apiClient<TelemetryHistoryResponse>(`/telemetry/${assetId}/history?days=${days}&limit=${limit}`, { token, cache: 'no-store' })
} }
export async function fetchAssetTelemetry(assetId: string, limit: number = 100): Promise<any> { export async function fetchAssetTelemetry(assetId: string, limit: number = 100): Promise<any> {

View File

@@ -0,0 +1,36 @@
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
interface DashboardEmptyStateProps {
icon: LucideIcon
label: string
title: string
description: string
}
export function DashboardEmptyState({
icon: Icon,
label,
title,
description,
}: DashboardEmptyStateProps) {
return (
<Card
aria-label={`${title}: ${description}`}
className="h-full rounded-[1.5rem] border-dashed border-[#2a3038] bg-[#171b20] shadow-sm shadow-black/10"
>
<CardContent className="flex min-h-64 items-center justify-center px-4 py-12 text-center">
<div className="max-w-md space-y-3 text-muted-foreground">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-[#2a3038] bg-primary/10 text-primary/80 shadow-lg shadow-orange-500/10">
<Icon className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">{label}</p>
<p className="text-base font-semibold text-foreground">{title}</p>
<p className="text-xs leading-relaxed">{description}</p>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,86 @@
import { Database, SignalHigh } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { cn } from '@/lib/utils/cn'
interface DashboardOperationalHeaderProps {
status: {
color: string
label: string
}
complianceStatus: {
samplesToday: number
latencyMs: number
}
assetOptions: Array<{ value: string; label: string }>
connectedWellCount: number
selectedAsset: string
setSelectedAsset: (assetId: string) => void
hasResolvedProject: boolean
}
export function DashboardOperationalHeader({
status,
complianceStatus,
assetOptions,
connectedWellCount,
selectedAsset,
setSelectedAsset,
hasResolvedProject,
}: DashboardOperationalHeaderProps) {
const connectedWellsLabel = `${connectedWellCount} ${connectedWellCount === 1 ? 'pozo conectado' : 'pozos conectados'}`
const scadaLinkLabel = status.label === 'Operacional' ? 'Enlace SCADA estable' : `Enlace SCADA ${status.label.toLowerCase()}`
const realAssetOptions = assetOptions.filter((option) => option.value !== 'all')
const shouldShowAssetSelect = realAssetOptions.length > 0 && assetOptions.length >= 2
const assetStatusLabel = hasResolvedProject
? 'Sin pozos configurados'
: 'Seleccioná un proyecto'
const statusTone = status.label === 'Operacional'
? 'border-[#27d796]/30 bg-[#27d796]/10'
: status.label === 'Desconectado'
? 'border-[#ff4655]/30 bg-[#ff4655]/10'
: 'border-[#ffb020]/30 bg-[#ffb020]/10'
return (
<div className="relative z-40 flex flex-col gap-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20 backdrop-blur md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-xl font-semibold tracking-tight text-foreground">Telemetría de Pozos</h1>
<Badge variant="outline" className={cn('text-xs', statusTone, status.color)}>
{scadaLinkLabel}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Database className="h-3 w-3" />
Telemetría activa · {connectedWellsLabel} · {complianceStatus.samplesToday} muestras hoy
</span>
<span className="flex items-center gap-1 lowercase">
<SignalHigh className="h-3 w-3" />
{' '}· latencia {complianceStatus.latencyMs} ms
</span>
</div>
</div>
<div className="flex items-center gap-2 rounded-2xl border border-[#2a3038] bg-[#171b20] px-3 py-2 shadow-lg shadow-black/20 backdrop-blur-sm">
<label className="text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">Alcance</label>
{shouldShowAssetSelect ? (
<Select
aria-label="Alcance de pozos"
className="min-w-40"
triggerClassName="bg-[#090b0f] border-[#2a3038] text-sm"
options={assetOptions}
value={selectedAsset}
onValueChange={setSelectedAsset}
/>
) : (
<Badge
variant="outline"
className="min-w-40 justify-center px-3 py-2 text-sm font-medium text-muted-foreground"
>
{assetStatusLabel}
</Badge>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,235 @@
import { AlertTriangle, Bell, CheckCircle2, Gauge, Radio, Settings2 } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import type { RecentAlertItem, VariableCategoryHealthItem, WellOverviewCard } from '../utils/dashboard-view-model'
interface DashboardPanoramaLowerSectionProps {
wells: WellOverviewCard[]
alerts: RecentAlertItem[]
categoryHealth: VariableCategoryHealthItem[]
overviewTitle: string
overviewEmptyTitle: string
overviewEmptyDescription: string
alertsTitle: string
alertsEmptyTitle: string
alertsEmptyDescription: string
}
function buildSparklinePath(values: number[]): string {
if (values.length < 2) return ''
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const step = 100 / (values.length - 1)
return values
.map((value, index) => {
const x = index * step
const y = 30 - ((value - min) / range) * 28
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
})
.join(' ')
}
function WellCard({ well }: { well: WellOverviewCard }) {
const sparklinePath = buildSparklinePath(well.sparklineValues)
const statusLabel = well.isActive ? 'Activo' : 'Inactivo'
return (
<article className="rounded-[1.4rem] border border-[#2a3038] bg-[#171b20] p-4 shadow-lg shadow-black/10">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn('size-2.5 rounded-full', well.isActive ? 'bg-[#27d796]' : 'bg-[#64748b]')} aria-hidden="true" />
<h3 className="truncate text-sm font-semibold text-foreground">{well.name}</h3>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{well.context || 'Sin contexto'}</p>
</div>
<span className={cn(
'rounded-full px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em]',
well.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-slate-500/10 text-slate-300'
)}>
{statusLabel}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
<p className="text-[10px] uppercase tracking-[0.18em] text-[#9aa8ba]">Variables</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{well.variablesCount}</p>
</div>
<div className="rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
<p className="text-[10px] uppercase tracking-[0.18em] text-[#9aa8ba]">Críticas</p>
<p className={cn('mt-1 text-2xl font-semibold tabular-nums', well.criticalCount > 0 ? 'text-[#ff4655]' : 'text-foreground')}>
{well.criticalCount}
</p>
</div>
</div>
<div className="mt-4 rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
{sparklinePath ? (
<svg viewBox="0 0 100 32" role="img" aria-label={`Tendencia real de ${well.name}`} className="h-12 w-full overflow-visible">
<path d={sparklinePath} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-[#00b7ff]" />
</svg>
) : (
<div className="flex h-12 items-center justify-center gap-2 text-xs text-muted-foreground">
<Radio className="size-4 text-[#64748b]" />
Sin señal
</div>
)}
</div>
</article>
)
}
function AlertRow({ alert }: { alert: RecentAlertItem }) {
const isCritical = alert.severity === 'critical'
const isWarning = alert.severity === 'warning'
const Icon = alert.acknowledged ? CheckCircle2 : isCritical || isWarning ? AlertTriangle : Bell
return (
<article className="rounded-2xl border border-[#2a3038] bg-[#171b20] p-3">
<div className="flex items-start gap-3">
<span className={cn(
'mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl',
alert.acknowledged ? 'bg-emerald-500/10 text-emerald-300' : isCritical ? 'bg-red-500/10 text-red-300' : 'bg-amber-500/10 text-amber-300'
)}>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn(
'rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.16em]',
alert.acknowledged ? 'bg-emerald-500/15 text-emerald-200' : isCritical ? 'bg-red-500/15 text-red-200' : 'bg-amber-500/15 text-amber-200'
)}>
{alert.acknowledged ? 'Reconocida' : alert.severityLabel}
</span>
<span className="text-[10px] text-muted-foreground">{alert.relativeTime}</span>
</div>
<p className="mt-1 truncate text-sm font-semibold text-foreground">{alert.variableName}</p>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{alert.message}</p>
<p className="mt-2 text-[10px] uppercase tracking-[0.14em] text-[#9aa8ba]">
{alert.assetLabel} · {alert.projectLabel}
</p>
</div>
</div>
</article>
)
}
function CategoryHealthPanel({ items }: { items: VariableCategoryHealthItem[] }) {
return (
<section className="rounded-[1.4rem] border border-[#2a3038] bg-[#171b20] p-4" aria-label="Salud por categoría de variable">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-foreground">Salud por categoría de variable</h2>
<p className="mt-1 text-xs text-muted-foreground">% de variables en rango normal</p>
</div>
<Gauge className="size-4 text-[#27d796]" />
</div>
{items.length === 0 ? (
<div className="mt-4 rounded-[1.2rem] border border-dashed border-[#2a3038] bg-[#15191e] p-5 text-center">
<Settings2 className="mx-auto size-5 text-[#64748b]" />
<p className="mt-3 text-sm font-semibold text-foreground">Sin categorías configuradas</p>
<p className="mx-auto mt-1 max-w-xl text-xs text-muted-foreground">
Las variables reales no exponen metadata de categoría. Configurá una categoría en metadata para habilitar este resumen sin inferirla desde alias.
</p>
</div>
) : (
<div className="mt-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
{items.map((item) => {
const hasStatus = item.normalPercentage !== null
const meterWidth = hasStatus ? `${item.normalPercentage}%` : '0%'
return (
<article key={item.category} className="rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{item.category}</p>
<p className="mt-0.5 text-[10px] uppercase tracking-[0.16em] text-[#9aa8ba]">
{item.total} {item.total === 1 ? 'variable' : 'variables'}
</p>
</div>
<span className={cn(
'text-2xl font-semibold tabular-nums',
hasStatus ? item.alarmCount > 0 ? 'text-[#ffb020]' : 'text-[#27d796]' : 'text-muted-foreground'
)}>
{hasStatus ? `${item.normalPercentage}%` : '—'}
</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#2a3038]" aria-hidden="true">
<div className="h-full rounded-full bg-[#27d796]" style={{ width: meterWidth }} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{hasStatus
? `${item.normalCount} normales · ${item.alarmCount} en alarma · ${item.knownStatusCount} con estado real`
: 'Sin estado real: faltan alarmas abiertas o umbrales con telemetría.'}
</p>
</article>
)
})}
</div>
)}
</section>
)
}
export function DashboardPanoramaLowerSection({
wells,
alerts,
categoryHealth,
overviewTitle,
overviewEmptyTitle,
overviewEmptyDescription,
alertsTitle,
alertsEmptyTitle,
alertsEmptyDescription,
}: DashboardPanoramaLowerSectionProps) {
return (
<section aria-label={`${overviewTitle} y ${alertsTitle}`} className="grid gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(320px,0.85fr)]">
<div className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Panorama</p>
<h2 className="text-sm font-semibold text-foreground">{overviewTitle}</h2>
</div>
<Gauge className="size-4 text-[#00b7ff]" />
</div>
{wells.length === 0 ? (
<div className="rounded-[1.4rem] border border-dashed border-[#2a3038] bg-[#171b20] p-6 text-center">
<p className="text-sm font-semibold text-foreground">{overviewEmptyTitle}</p>
<p className="mt-1 text-xs text-muted-foreground">{overviewEmptyDescription}</p>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
{wells.map((well) => <WellCard key={well.id} well={well} />)}
</div>
)}
<CategoryHealthPanel items={categoryHealth} />
</div>
<aside className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20" aria-label={alertsTitle}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Alarmas</p>
<h2 className="text-sm font-semibold text-foreground">{alertsTitle}</h2>
</div>
<span className="rounded-full bg-[#ffb020]/10 px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-[#ffb020]">
{alerts.length}
</span>
</div>
{alerts.length === 0 ? (
<div className="rounded-[1.4rem] border border-dashed border-[#2a3038] bg-[#171b20] p-6 text-center">
<p className="text-sm font-semibold text-foreground">{alertsEmptyTitle}</p>
<p className="mt-1 text-xs text-muted-foreground">{alertsEmptyDescription}</p>
</div>
) : (
<div className="space-y-3">
{alerts.map((alert) => <AlertRow key={alert.id} alert={alert} />)}
</div>
)}
</aside>
</section>
)
}

View File

@@ -0,0 +1,81 @@
import { Radio, Wifi } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import type { ComplianceStatus, DashboardStatus, PanoramaMetric } from '../utils/dashboard-view-model'
interface MetricCardProps {
metric: PanoramaMetric
}
interface DashboardPanoramaOverviewProps {
status: DashboardStatus
complianceStatus: ComplianceStatus
connectedWellCount: number
signalCount: number
metrics: PanoramaMetric[]
}
function formatNumber(value: number | null): string {
if (value === null) return '—'
return value.toLocaleString('es')
}
function MetricCard({ metric }: MetricCardProps) {
const Icon = metric.icon
return (
<article className="rounded-xl border border-[#2a3038] bg-[#15191e] p-4 shadow-lg shadow-black/10">
<div className="flex items-start justify-between">
<span className={cn('flex size-9 items-center justify-center rounded-lg bg-[#171b20]', metric.accent ?? 'text-primary')}>
<Icon className="size-4" />
</span>
</div>
<p className="mt-3 text-[11px] uppercase tracking-wider text-[#9aa8ba]">{metric.label}</p>
<p className="mt-0.5 text-2xl font-semibold tabular-nums text-foreground">{formatNumber(metric.value)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{metric.sub}</p>
</article>
)
}
export function DashboardPanoramaOverview({
status,
complianceStatus,
connectedWellCount,
signalCount,
metrics,
}: DashboardPanoramaOverviewProps) {
const connectedWellsLabel = `${connectedWellCount} ${connectedWellCount === 1 ? 'pozo conectado' : 'pozos conectados'}`
const scadaLinkLabel = status.label === 'Operacional' ? 'Enlace SCADA estable' : `Enlace SCADA ${status.label.toLowerCase()}`
const isOperational = status.label === 'Operacional'
return (
<section aria-label="Panorama operacional" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#2a3038] bg-[#15191e] px-4 py-3 shadow-lg shadow-black/10">
<div className="flex items-center gap-2">
<span className="relative flex size-2.5" aria-hidden="true">
{isOperational && <span className="absolute inline-flex size-full animate-ping rounded-full bg-[#27d796] opacity-60" />}
<span className={cn('relative inline-flex size-2.5 rounded-full', isOperational ? 'bg-[#27d796]' : 'bg-[#ffb020]')} />
</span>
<span className="text-sm font-medium text-foreground">
Telemetría activa <span className="text-xs font-normal text-muted-foreground">· {connectedWellsLabel} · latencia {complianceStatus.latencyMs} ms</span>
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Wifi className={cn('size-3.5', status.color)} />
{scadaLinkLabel}
</span>
<span className="flex items-center gap-1.5">
<Radio className="size-3.5 text-primary" />
{formatNumber(signalCount)} señales recibidas
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-7">
{metrics.map((metric) => (
<MetricCard key={metric.label} metric={metric} />
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,111 @@
import { memo } from 'react'
import { Eye, EyeOff, TrendingUp } from 'lucide-react'
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils/cn'
import type { ChartGroup } from '../utils/dashboard-view-model'
interface TelemetryChartProps {
group: ChartGroup
gIdx: number
visibleVars: Record<string, boolean>
toggleVariable: (varName: string) => void
getBgForUnit: (unit?: string) => string
getColorForUnit: (unit?: string) => string
}
const chartStrokePalette = ['#00b7ff', '#27d796', '#ff7a1a', '#ffb020', '#ff4655', '#a8b3c4']
export const TelemetryChart = memo(({
group,
gIdx,
visibleVars,
toggleVariable,
getBgForUnit,
getColorForUnit,
}: TelemetryChartProps) => (
<Card
className="overflow-hidden border-[#2a3038] bg-[#15191e] shadow-sm shadow-black/20"
aria-label={`${group.unit} telemetry panel with ${group.variables.length} ${group.variables.length === 1 ? 'variable' : 'variables'}`}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 border-b border-[#2a3038] bg-[#171b20] px-4 py-3">
<div className="flex items-center gap-3">
<div className={cn('rounded-lg border border-[#2a3038] p-1.5 shadow-inner', getBgForUnit(group.unit))}>
<TrendingUp className={cn('h-4 w-4', getColorForUnit(group.unit))} />
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-[#9aa8ba]">Serie SCADA</p>
<h3 className="mt-0.5 text-sm font-semibold text-foreground">{group.unit}</h3>
<p className="text-xs text-muted-foreground">Analisis historico</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{group.variables.map((v) => (
<button
key={v}
onClick={() => toggleVariable(v)}
aria-pressed={visibleVars[v] !== false}
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors',
visibleVars[v] === false
? 'border-[#2a3038] bg-[#090b0f] text-muted-foreground opacity-60'
: 'border-primary/30 bg-primary/10 text-foreground shadow-sm shadow-primary/10'
)}
>
{visibleVars[v] === false ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{v}
</button>
))}
</div>
</div>
<div className="h-64 p-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={group.data}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="time"
stroke="rgba(255,255,255,0.15)"
fontSize={10}
tickLine={false}
axisLine={false}
minTickGap={40}
dy={8}
/>
<YAxis
stroke="rgba(255,255,255,0.15)"
fontSize={10}
tickLine={false}
axisLine={false}
dx={-8}
/>
<Tooltip
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: '8px', padding: '10px' }}
itemStyle={{ fontSize: '12px' }}
labelStyle={{ opacity: 0.5, fontSize: '11px', marginBottom: '6px' }}
/>
{group.variables.map((v, vIdx) => (
<Line
key={v}
type="linear"
dataKey={v}
hide={visibleVars[v] === false}
stroke={chartStrokePalette[(vIdx + gIdx) % chartStrokePalette.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0, fill: '#fff' }}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</Card>
))

View File

@@ -1,11 +1,34 @@
import { act } from 'react' import { act } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Activity } from 'lucide-react'
import DashboardPage from './DashboardPage' import DashboardPage from './DashboardPage'
import { DashboardOperationalHeader } from '../components/DashboardOperationalHeader'
import { DashboardEmptyState } from '../components/DashboardEmptyState'
import { TelemetryChart } from '../components/TelemetryChart'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api' import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api'
import { fetchAssetTelemetry, fetchDashboardSummary } from '../api/dashboard-api' import { fetchAssetTelemetry, fetchDashboardSummary } from '../api/dashboard-api'
import { listAssets } from '../../projects/api/wells-api' import { listAssets } from '../../projects/api/wells-api'
import { listDevices } from '../../projects/api/devices-api'
import { listVariables } from '../../projects/api/variables-api'
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
import { deduceUnit, getAssetProjectId, normalizeTelemetry } from '../utils/telemetry-normalization'
import {
buildAssetOptions,
buildDynamicStats,
buildRecentAlerts,
buildPanoramaSummary,
buildVariableCategoryHealth,
buildWellOverviewCards,
countTelemetryVariables,
countConnectedWells,
getComplianceStatus,
getDashboardStatus,
getUnitBackgroundClass,
getUnitColorClass,
groupChartData,
} from '../utils/dashboard-view-model'
vi.mock('../api/dashboard-api', () => ({ vi.mock('../api/dashboard-api', () => ({
fetchDashboardSummary: vi.fn(), fetchDashboardSummary: vi.fn(),
@@ -16,6 +39,18 @@ vi.mock('../../projects/api/wells-api', () => ({
listAssets: vi.fn(), listAssets: vi.fn(),
})) }))
vi.mock('../../projects/api/devices-api', () => ({
listDevices: vi.fn(),
}))
vi.mock('../../projects/api/variables-api', () => ({
listVariables: vi.fn(),
}))
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
getAlarms: vi.fn(),
}))
vi.mock('@/features/admin/telemetry/api/telemetry-ws-api', () => ({ vi.mock('@/features/admin/telemetry/api/telemetry-ws-api', () => ({
createTelemetryWsTicket: vi.fn(() => new Promise(() => {})), createTelemetryWsTicket: vi.fn(() => new Promise(() => {})),
})) }))
@@ -37,6 +72,9 @@ describe('DashboardPage well selector', () => {
root = createRoot(container) root = createRoot(container)
vi.mocked(fetchDashboardSummary).mockResolvedValue({ data: [] }) vi.mocked(fetchDashboardSummary).mockResolvedValue({ data: [] })
vi.mocked(fetchAssetTelemetry).mockResolvedValue([]) vi.mocked(fetchAssetTelemetry).mockResolvedValue([])
vi.mocked(getAlarms).mockResolvedValue([])
vi.mocked(listDevices).mockResolvedValue([])
vi.mocked(listVariables).mockResolvedValue([])
vi.mocked(listAssets).mockResolvedValue([ vi.mocked(listAssets).mockResolvedValue([
{ {
id: 'asset-uuid-pozo-1', id: 'asset-uuid-pozo-1',
@@ -92,6 +130,7 @@ describe('DashboardPage well selector', () => {
await flushEffects() await flushEffects()
expect(listAssets).toHaveBeenCalledWith('project-1') expect(listAssets).toHaveBeenCalledWith('project-1')
expect(getAlarms).toHaveBeenCalledWith('project-1')
const trigger = container.querySelector('button[aria-haspopup="listbox"]') as HTMLButtonElement const trigger = container.querySelector('button[aria-haspopup="listbox"]') as HTMLButtonElement
expect(trigger.textContent).toContain('Todos los pozos') expect(trigger.textContent).toContain('Todos los pozos')
@@ -115,6 +154,52 @@ describe('DashboardPage well selector', () => {
expect(fetchAssetTelemetry).toHaveBeenCalledWith('asset-uuid-pozo-1', 100) expect(fetchAssetTelemetry).toHaveBeenCalledWith('asset-uuid-pozo-1', 100)
}) })
it('shows a non-interactive asset status when the project has no configured wells', async () => {
vi.mocked(listAssets).mockResolvedValue([
{
id: 'asset-uuid-tanque-1',
project_id: 'project-1',
code: 'TK-01',
name: 'Tanque Norte',
asset_type: 'TANQUE',
is_active: true,
metadata: {},
is_collector_enabled: false,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
])
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
expect(container.textContent).toContain('Alcance')
expect(container.textContent).toContain('Sin pozos configurados')
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
})
it('shows a project selection state instead of no wells when no project is resolved', async () => {
useAuthStore.setState({
activeProjectId: null,
projects: [
{ id: 'project-1', name: 'Campo Norte' },
{ id: 'project-2', name: 'Campo Sur' },
],
})
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
expect(listAssets).not.toHaveBeenCalled()
expect(container.textContent).toContain('Seleccioná un proyecto')
expect(container.textContent).not.toContain('Sin pozos configurados')
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
})
it('falls back to code and short id labels for unnamed POZO assets', async () => { it('falls back to code and short id labels for unnamed POZO assets', async () => {
vi.mocked(listAssets).mockResolvedValue([ vi.mocked(listAssets).mockResolvedValue([
{ {
@@ -223,7 +308,7 @@ describe('DashboardPage well selector', () => {
} }
vi.stubGlobal('WebSocket', FakeWebSocket) vi.stubGlobal('WebSocket', FakeWebSocket)
vi.mocked(createTelemetryWsTicket).mockResolvedValue({ ticket: 'ticket-1' }) vi.mocked(createTelemetryWsTicket).mockResolvedValue({ ticket: 'ticket-1', expires_in: 300 })
useAuthStore.setState({ useAuthStore.setState({
activeProjectId: null, activeProjectId: null,
projects: [ projects: [
@@ -266,7 +351,7 @@ describe('DashboardPage well selector', () => {
expect(optionLabels).toEqual(expect.arrayContaining(['Pozo Norte (PZ-01)'])) expect(optionLabels).toEqual(expect.arrayContaining(['Pozo Norte (PZ-01)']))
}) })
it('resets to all wells when created well loading fails', async () => { it('resets selection and shows a non-interactive status when created well loading fails', async () => {
await act(async () => { await act(async () => {
root.render(<DashboardPage />) root.render(<DashboardPage />)
}) })
@@ -293,7 +378,757 @@ describe('DashboardPage well selector', () => {
}) })
await flushEffects() await flushEffects()
expect(container.querySelector('button[aria-haspopup="listbox"]')?.textContent).toContain('Todos los pozos') expect(container.textContent).toContain('Sin pozos configurados')
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
expect(fetchAssetTelemetry).not.toHaveBeenCalled() expect(fetchAssetTelemetry).not.toHaveBeenCalled()
}) })
it('renders SCADA body section labels and empty telemetry states from real empty data', async () => {
vi.mocked(listAssets).mockResolvedValue([])
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
expect(container.textContent).toContain('Panel SCADA')
expect(container.textContent).toContain('Estado de sensores')
expect(container.textContent).toContain('Sin señal de sensores')
expect(container.textContent).toContain('Esperando el primer paquete de telemetría del pozo seleccionado.')
expect(container.textContent).toContain('Serie histórica')
expect(container.textContent).toContain('Telemetría en espera')
expect(container.textContent).toContain('La serie histórica aparecerá cuando ingresen muestras válidas.')
})
it('renders well overview cards and recent alerts from real assets, telemetry, and alarms', async () => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [
{
asset_id: 'asset-uuid-pozo-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 520, temp_c: 84 },
},
],
})
vi.mocked(getAlarms).mockResolvedValue([
{
id: 'alarm-1',
project_id: 'project-1',
asset_id: 'asset-uuid-pozo-1',
variable_alias: 'pressure_psi',
current_value: 700,
alarm_min: null,
alarm_max: 650,
alarm_type: 'ABOVE_MAX',
message: 'Presión sobre el máximo',
acknowledged: false,
timestamp: '2026-06-22T00:00:00Z',
},
])
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
expect(container.textContent).toContain('Vista general por pozo')
expect(container.textContent).toContain('Pozo Norte')
expect(container.textContent).toContain('PZ-01')
expect(container.textContent).toContain('Variables2')
expect(container.textContent).toContain('Críticas1')
expect(container.textContent).toContain('Sin señal')
expect(container.textContent).toContain('Alertas recientes')
expect(container.textContent).toContain('pressure_psi')
expect(container.textContent).toContain('Presión sobre el máximo')
})
it('renders Panorama status strip and KPI row from real dashboard data without demo counts', async () => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [
{
asset_id: 'asset-uuid-pozo-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 520, temp_c: 84 },
},
],
})
vi.mocked(getAlarms).mockResolvedValue([
{
id: 'alarm-1',
project_id: 'project-1',
asset_id: 'asset-uuid-pozo-1',
variable_alias: 'pressure_psi',
current_value: 700,
alarm_min: null,
alarm_max: 650,
alarm_type: 'ABOVE_MAX',
message: 'Presión sobre el máximo',
acknowledged: false,
timestamp: '2026-06-22T00:00:00Z',
},
])
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
expect(container.textContent).toContain('Telemetría activa · 1 pozo conectado · latencia 0 ms')
expect(container.textContent).toContain('2 señales recibidas')
expect(container.textContent).toContain('Pozos1en operación')
expect(container.textContent).toContain('Variables2monitoreadas')
expect(container.textContent).toContain('Alertas1activas')
expect(container.textContent).toContain('Alta frec.—sin fuente configurada')
expect(container.textContent).toContain('Baja frec.—sin fuente configurada')
expect(container.textContent).toContain('Con falla—sin fuente configurada')
expect(container.textContent).toContain('Producción—sin fuente configurada')
})
it('sums repeated per-well variable aliases for the Panorama Variables KPI', async () => {
vi.mocked(listAssets).mockResolvedValue([
{
id: 'asset-uuid-pozo-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: false,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
{
id: 'asset-uuid-pozo-2',
project_id: 'project-1',
code: 'PZ-02',
name: 'Pozo Sur',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: false,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
])
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [
{
asset_id: 'asset-uuid-pozo-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 520, temp_c: 84 },
},
{
asset_id: 'asset-uuid-pozo-2',
asset: 'Pozo Sur',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 700, caudal_bpd: 120 },
},
],
})
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
expect(container.textContent).toContain('3 señales recibidas')
expect(container.textContent).toContain('Variables4monitoreadas')
})
it('shows only the selected well variable count in the Panorama Variables KPI', async () => {
vi.mocked(listAssets).mockResolvedValue([
{
id: 'asset-uuid-pozo-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: false,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
{
id: 'asset-uuid-pozo-2',
project_id: 'project-1',
code: 'PZ-02',
name: 'Pozo Sur',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: false,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
])
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [
{
asset_id: 'asset-uuid-pozo-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 520, temp_c: 84, motor_amp: 22 },
},
{
asset_id: 'asset-uuid-pozo-2',
asset: 'Pozo Sur',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 700, caudal_bpd: 120 },
},
],
})
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
const trigger = container.querySelector('button[aria-haspopup="listbox"]') as HTMLButtonElement
await act(async () => {
trigger.click()
})
const wellOption = Array.from(document.body.querySelectorAll('[role="option"]'))
.find((option) => option.textContent === 'Pozo Sur (PZ-02)') as HTMLButtonElement
await act(async () => {
wellOption.click()
})
await flushEffects()
expect(container.textContent).toContain('2 señales recibidas')
expect(container.textContent).toContain('Variables2monitoreadas')
})
it('renders an honest category-health empty state when real variables have no category metadata', async () => {
vi.mocked(listDevices).mockResolvedValue([{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' }])
vi.mocked(listVariables).mockResolvedValue([
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'pressure_psi', unit: 'PSI', polling_interval_ms: 5000, alarm_min: 100, alarm_max: 650, alarm_enabled: true, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
])
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
await flushEffects()
expect(container.textContent).toContain('Salud por categoría de variable')
expect(container.textContent).toContain('% de variables en rango normal')
expect(container.textContent).toContain('Sin categorías configuradas')
expect(container.textContent).toContain('no exponen metadata de categoría')
})
it('renders variable category health from real variable metadata, telemetry thresholds, and alarms', async () => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [
{
asset_id: 'asset-uuid-pozo-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 520, temp_c: 84 },
},
],
})
vi.mocked(listDevices).mockResolvedValue([{ id: 'device-1', asset_id: 'asset-uuid-pozo-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' }])
vi.mocked(listVariables).mockResolvedValue([
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'pressure_psi', unit: 'PSI', polling_interval_ms: 5000, alarm_min: 100, alarm_max: 650, alarm_enabled: true, byte_order: 'BE', metadata: { category: 'Presión de línea' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-2', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'temp_c', unit: '°C', polling_interval_ms: 5000, alarm_min: 20, alarm_max: 80, alarm_enabled: true, byte_order: 'BE', metadata: { variable_category: 'Temperatura real' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
])
await act(async () => {
root.render(<DashboardPage />)
})
await flushEffects()
await flushEffects()
expect(container.textContent).toContain('Presión de línea')
expect(container.textContent).toContain('Temperatura real')
expect(container.textContent).toContain('100%')
expect(container.textContent).toContain('0%')
expect(container.textContent).toContain('1 normales · 0 en alarma · 1 con estado real')
expect(container.textContent).toContain('0 normales · 1 en alarma · 1 con estado real')
})
})
describe('dashboard telemetry helpers', () => {
it('normalizes enriched, values, and flat telemetry payloads with existing unit defaults', () => {
expect(normalizeTelemetry({ variables: { pressure: { value: '520', unit: 'PSI' } } })).toEqual({
pressure: { value: 520, unit: 'PSI' },
})
expect(normalizeTelemetry({ values: { temperatura_c: '87.5', caudal_bpd: 120 } })).toEqual({
temperatura_c: { value: 87.5, unit: '°C' },
caudal_bpd: { value: 120, unit: 'BPD' },
})
expect(normalizeTelemetry({ project_id: 'project-1', pressure_psi: 515, asset: 'Pozo Norte' })).toEqual({
pressure_psi: { value: 515, unit: 'PSI' },
})
expect(normalizeTelemetry(null)).toEqual({})
})
it('deduces units and project identity using existing fallback rules', () => {
expect(deduceUnit('nivel_pies')).toBe('pies')
expect(deduceUnit('volumen_bbl')).toBe('BBL')
expect(deduceUnit('raw_counter')).toBe('RAW')
expect(getAssetProjectId({
asset_id: 'asset-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { project_id: 'project-from-telemetry' },
})).toBe('project-from-telemetry')
})
})
describe('dashboard view-model helpers', () => {
it('builds dynamic stats with existing unit styles and realtime trend', () => {
expect(buildDynamicStats({
asset_id: 'asset-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: {
variables: {
pressure: { value: '520.5', unit: 'PSI' },
temperature: { value: 88, unit: '°C' },
},
},
}).map(({ icon: _icon, ...stat }) => stat)).toEqual([
{
label: 'pressure',
value: 520.5,
unit: 'PSI',
color: 'text-[#00b7ff]',
bg: 'bg-[#00b7ff]/10',
trend: 'Realtime',
},
{
label: 'temperature',
value: 88,
unit: '°C',
color: 'text-[#ff7a1a]',
bg: 'bg-[#ff7a1a]/10',
trend: 'Realtime',
},
])
expect(buildDynamicStats(null)).toEqual([])
expect(countConnectedWells([
{
asset_id: 'asset-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 515 },
},
{
asset_id: 'asset-2',
asset: 'Pozo Sur',
last_updated: '2026-06-22T00:00:00Z',
telemetry: {},
},
], [{ id: 'asset-1' }, { id: 'asset-2' }])).toBe(1)
expect(getUnitColorClass('BPD')).toBe('text-[#27d796]')
expect(getUnitBackgroundClass('RAW')).toBe('bg-primary/10')
})
it('builds Panorama metrics from real assets and marks unsupported business metrics as unavailable', () => {
const assets = [
{
asset_id: 'asset-1',
asset: 'Pozo Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 515, temp_c: 80 },
},
{
asset_id: 'asset-2',
asset: 'Pozo Sur',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { pressure_psi: 700, caudal_bpd: 120 },
},
{
asset_id: 'tank-1',
asset: 'Tanque Norte',
last_updated: '2026-06-22T00:00:00Z',
telemetry: { nivel_pies: 11 },
},
]
const wellAssets = [{ id: 'asset-1' }, { id: 'asset-2' }]
expect(countTelemetryVariables(assets, wellAssets)).toBe(3)
const summary = buildPanoramaSummary(assets, wellAssets, [
{
id: 'alarm-1',
project_id: 'project-1',
asset_id: 'asset-1',
variable_alias: 'pressure_psi',
alarm_type: 'ABOVE_MAX',
message: 'Pressure high',
acknowledged: false,
timestamp: '2026-06-22T12:00:00Z',
},
{
id: 'alarm-2',
project_id: 'project-1',
asset_id: 'asset-2',
variable_alias: 'caudal_bpd',
alarm_type: 'BELOW_MIN',
message: 'Flow low',
acknowledged: true,
timestamp: '2026-06-22T11:00:00Z',
},
], 4)
expect(summary.connectedWellCount).toBe(2)
expect(summary.signalCount).toBe(3)
expect(summary.metrics.map(({ icon: _icon, ...metric }) => metric)).toEqual([
{ label: 'Pozos', value: 2, sub: 'en operación' },
{ label: 'Variables', value: 4, sub: 'monitoreadas' },
{ label: 'Alertas', value: 1, sub: 'activas', accent: 'text-[#ffb020]' },
{ label: 'Alta frec.', value: null, sub: 'sin fuente configurada', accent: 'text-[#00b7ff]' },
{ label: 'Baja frec.', value: null, sub: 'sin fuente configurada', accent: 'text-[#27d796]' },
{ label: 'Con falla', value: null, sub: 'sin fuente configurada', accent: 'text-[#ff4655]' },
{ label: 'Producción', value: null, sub: 'sin fuente configurada', accent: 'text-primary' },
])
})
it('builds well overview and recent alerts from associated real records only', () => {
const wells = [
{ id: 'asset-1', code: 'PZ-01', name: 'Pozo Norte', project_id: 'project-1', is_active: true },
{ id: 'asset-2', code: 'PZ-02', name: 'Pozo Sur', project_id: 'project-1', is_active: false },
]
const alarms = [
{
id: 'alarm-1',
project_id: 'project-1',
asset_id: 'asset-1',
variable_alias: 'pressure_psi',
alarm_type: 'ABOVE_MAX',
message: 'Pressure high',
acknowledged: false,
timestamp: '2026-06-22T12:00:00Z',
},
{
id: 'alarm-2',
project_id: 'project-1',
asset_id: 'asset-2',
variable_alias: 'temp_c',
alarm_type: 'BELOW_MIN',
message: 'Temperature low',
acknowledged: true,
timestamp: '2026-06-22T11:00:00Z',
},
]
const cards = buildWellOverviewCards(
wells,
[{ asset_id: 'asset-1', asset: 'Pozo Norte', last_updated: '2026-06-22T12:00:00Z', telemetry: { pressure_psi: 520, temp_c: 80 } }],
alarms,
[
{ time: '2026-06-22T11:58:00Z', data: { pressure_psi: 510 } },
{ time: '2026-06-22T11:59:00Z', data: { pressure_psi: 520 } },
],
'asset-1',
'Campo Norte'
)
expect(cards).toEqual([
expect.objectContaining({
id: 'asset-1',
name: 'Pozo Norte',
context: 'PZ-01 · Campo Norte',
isActive: true,
variablesCount: 2,
criticalCount: 1,
hasTelemetry: true,
sparklineValues: [510, 520],
}),
expect.objectContaining({
id: 'asset-2',
variablesCount: 0,
criticalCount: 0,
hasTelemetry: false,
sparklineValues: [],
}),
])
expect(buildRecentAlerts(alarms, wells, 'Campo Norte', new Date('2026-06-22T12:05:00Z'))).toEqual([
expect.objectContaining({
id: 'alarm-1',
severity: 'critical',
severityLabel: 'Crítica',
variableName: 'pressure_psi',
message: 'Pressure high',
assetLabel: 'Pozo Norte',
projectLabel: 'Campo Norte',
relativeTime: 'Hace 5 min',
acknowledged: false,
}),
expect.objectContaining({
id: 'alarm-2',
severity: 'warning',
acknowledged: true,
}),
])
})
it('builds variable category health only from explicit metadata and real status signals', () => {
const variables = [
{ id: 'var-1', device_id: 'device-1', assetId: 'asset-1', address: '40001', data_type: 'float32' as const, alias: 'pressure_psi', unit: 'PSI', polling_interval_ms: 5000, alarm_min: 100, alarm_max: 650, alarm_enabled: true, byte_order: 'BE', metadata: { category: 'Presión real' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-2', device_id: 'device-1', assetId: 'asset-1', address: '40002', data_type: 'float32' as const, alias: 'temp_c', unit: '°C', polling_interval_ms: 5000, alarm_min: 20, alarm_max: 80, alarm_enabled: true, byte_order: 'BE', metadata: { variableCategory: 'Temperatura real' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-3', device_id: 'device-1', assetId: 'asset-1', address: '40003', data_type: 'float32' as const, alias: 'caudal_bpd', unit: 'BPD', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'BE', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
]
expect(buildVariableCategoryHealth(
variables,
[{ asset_id: 'asset-1', asset: 'Pozo Norte', last_updated: '2026-06-22T00:00:00Z', telemetry: { pressure_psi: 520, temp_c: 84, caudal_bpd: 100 } }],
[],
[{ id: 'asset-1' }]
)).toEqual([
expect.objectContaining({ category: 'Presión real', total: 1, knownStatusCount: 1, normalCount: 1, alarmCount: 0, normalPercentage: 100 }),
expect.objectContaining({ category: 'Temperatura real', total: 1, knownStatusCount: 1, normalCount: 0, alarmCount: 1, normalPercentage: 0 }),
])
})
it('groups chart history by unit and selected asset using current timestamp labels', () => {
const grouped = groupChartData([
{ time: '2026-06-22T10:00:00Z', asset_id: 'asset-1', data: { pressure_psi: 510, temp_c: 80 } },
{ time: '2026-06-22T10:01:00Z', asset_id: 'asset-2', data: { pressure_psi: 700 } },
{ time: '2026-06-22T10:02:00Z', asset_id: 'asset-1', data: { pressure_psi: 520, caudal_bpd: 120 } },
], 'asset-1')
expect(grouped.map((group) => group.unit)).toEqual(['PSI', 'BPD', '°C'])
expect(grouped.find((group) => group.unit === 'PSI')?.variables).toEqual(['pressure_psi'])
expect(grouped.find((group) => group.unit === 'PSI')?.data).toHaveLength(2)
expect(grouped.find((group) => group.unit === 'BPD')?.data[0]?.caudal_bpd).toBe(120)
expect(groupChartData([], 'all')).toEqual([])
})
it('derives compliance, status, and asset option labels with existing defaults', () => {
const compliance = getComplianceStatus([
{ time: '2026-06-22T11:59:50Z', asset_id: 'asset-1', data: { pressure_psi: 520 } },
{ time: '2026-06-22T08:00:00Z', asset_id: 'asset-2', data: { pressure_psi: 700 } },
], 'asset-1', new Date('2026-06-22T12:00:00Z'), 17)
expect(compliance).toEqual({ samplesToday: 1, isStable: true, latencyMs: 17 })
expect(getDashboardStatus(false, true)).toEqual({ color: 'text-[#ff4655]', label: 'Desconectado' })
expect(getDashboardStatus(true, false)).toEqual({ color: 'text-[#ffb020]', label: 'Señal retrasada' })
expect(getDashboardStatus(true, true)).toEqual({ color: 'text-[#27d796]', label: 'Operacional' })
expect(buildAssetOptions([
{ id: 'asset-1', code: 'PZ-01', name: 'Pozo Norte' },
{ id: 'asset-code', code: 'PZ-02', name: '' },
{ id: '12345678-unnamed', code: '', name: '' },
])).toEqual([
{ value: 'all', label: 'Todos los pozos' },
{ value: 'asset-1', label: 'Pozo Norte (PZ-01)' },
{ value: 'asset-code', label: 'PZ-02' },
{ value: '12345678-unnamed', label: '12345678' },
])
})
})
describe('dashboard presentational components', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => {
root.unmount()
})
document.body.innerHTML = ''
})
it('renders a dashboard empty state with SCADA label, title, and guidance copy', async () => {
await act(async () => {
root.render(
<DashboardEmptyState
icon={Activity}
label="Estado SCADA"
title="Sin señal de sensores"
description="Esperando el primer paquete de telemetría del pozo seleccionado."
/>
)
})
expect(container.textContent).toContain('Estado SCADA')
expect(container.textContent).toContain('Sin señal de sensores')
expect(container.textContent).toContain('Esperando el primer paquete de telemetría del pozo seleccionado.')
expect(container.querySelector('[aria-label="Sin señal de sensores: Esperando el primer paquete de telemetría del pozo seleccionado."]')).not.toBeNull()
})
it('renders telemetry chart SCADA heading and variable toggle labels unchanged', async () => {
const toggleVariable = vi.fn()
await act(async () => {
root.render(
<TelemetryChart
group={{
unit: 'PSI',
variables: ['pressure_psi', 'intake_pressure_psi'],
data: [
{ time: '10:00', pressure_psi: 520, intake_pressure_psi: 115 },
{ time: '10:01', pressure_psi: 521, intake_pressure_psi: 116 },
],
}}
gIdx={0}
visibleVars={{ intake_pressure_psi: false }}
toggleVariable={toggleVariable}
getBgForUnit={getUnitBackgroundClass}
getColorForUnit={getUnitColorClass}
/>
)
})
expect(container.textContent).toContain('PSI')
expect(container.textContent).toContain('Serie SCADA')
expect(container.textContent).toContain('Analisis historico')
expect(container.textContent).toContain('pressure_psi')
expect(container.textContent).toContain('intake_pressure_psi')
expect(container.querySelector('[aria-label="PSI telemetry panel with 2 variables"]')).not.toBeNull()
expect(container.querySelector('button[aria-pressed="true"]')?.textContent).toContain('pressure_psi')
expect(container.querySelector('button[aria-pressed="false"]')?.textContent).toContain('intake_pressure_psi')
const pressureButton = Array.from(container.querySelectorAll('button'))
.find((button) => button.textContent === 'pressure_psi') as HTMLButtonElement
await act(async () => {
pressureButton.click()
})
expect(toggleVariable).toHaveBeenCalledWith('pressure_psi')
})
it('summarizes a single RAW telemetry chart without changing toggle callbacks', async () => {
const toggleVariable = vi.fn()
await act(async () => {
root.render(
<TelemetryChart
group={{
unit: 'RAW',
variables: ['runtime_counter'],
data: [
{ time: '10:00', runtime_counter: 0 },
],
}}
gIdx={1}
visibleVars={{ runtime_counter: true }}
toggleVariable={toggleVariable}
getBgForUnit={getUnitBackgroundClass}
getColorForUnit={getUnitColorClass}
/>
)
})
expect(container.textContent).toContain('RAW')
expect(container.textContent).toContain('Serie SCADA')
expect(container.querySelector('[aria-label="RAW telemetry panel with 1 variable"]')).not.toBeNull()
const runtimeButton = container.querySelector('button[aria-pressed="true"]') as HTMLButtonElement
await act(async () => {
runtimeButton.click()
})
expect(toggleVariable).toHaveBeenCalledWith('runtime_counter')
})
it('renders the dashboard header with SCADA telemetry copy derived from real props', async () => {
const setSelectedAsset = vi.fn()
await act(async () => {
root.render(
<DashboardOperationalHeader
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
complianceStatus={{ samplesToday: 7, latencyMs: 17 }}
assetOptions={[
{ value: 'all', label: 'Todos los pozos' },
{ value: 'asset-1', label: 'Pozo Norte (PZ-01)' },
]}
connectedWellCount={1}
selectedAsset="all"
setSelectedAsset={setSelectedAsset}
hasResolvedProject
/>
)
})
expect(container.textContent).toContain('Telemetría de Pozos')
expect(container.textContent).toContain('Telemetría activa · 1 pozo conectado · 7 muestras hoy · latencia 17 ms')
expect(container.textContent).toContain('Enlace SCADA estable')
expect(container.textContent).toContain('Alcance')
expect(container.querySelector('button[aria-haspopup="listbox"]')?.textContent).toContain('Todos los pozos')
})
it('pluralizes connected wells and reflects non-operational SCADA status', async () => {
const setSelectedAsset = vi.fn()
await act(async () => {
root.render(
<DashboardOperationalHeader
status={{ color: 'text-[#ffb020]', label: 'Señal retrasada' }}
complianceStatus={{ samplesToday: 2, latencyMs: 28 }}
assetOptions={[
{ value: 'all', label: 'Todos los pozos' },
{ value: 'asset-1', label: 'Pozo Norte (PZ-01)' },
{ value: 'asset-2', label: 'Pozo Sur (PZ-02)' },
]}
connectedWellCount={2}
selectedAsset="asset-2"
setSelectedAsset={setSelectedAsset}
hasResolvedProject
/>
)
})
expect(container.textContent).toContain('Telemetría activa · 2 pozos conectados · 2 muestras hoy · latencia 28 ms')
expect(container.textContent).toContain('Enlace SCADA señal retrasada')
expect(container.querySelector('button[aria-haspopup="listbox"]')?.textContent).toContain('Pozo Sur (PZ-02)')
})
it('renders a status chip instead of a selector when only the synthetic all option exists', async () => {
const setSelectedAsset = vi.fn()
await act(async () => {
root.render(
<DashboardOperationalHeader
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
complianceStatus={{ samplesToday: 0, latencyMs: 0 }}
assetOptions={[{ value: 'all', label: 'Todos los pozos' }]}
selectedAsset="all"
setSelectedAsset={setSelectedAsset}
hasResolvedProject
/>
)
})
expect(container.textContent).toContain('Sin pozos configurados')
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
})
it('renders a project selection chip before a project is resolved', async () => {
const setSelectedAsset = vi.fn()
await act(async () => {
root.render(
<DashboardOperationalHeader
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
complianceStatus={{ samplesToday: 0, latencyMs: 0 }}
assetOptions={[{ value: 'all', label: 'Todos los pozos' }]}
selectedAsset="all"
setSelectedAsset={setSelectedAsset}
hasResolvedProject={false}
/>
)
})
expect(container.textContent).toContain('Seleccioná un proyecto')
expect(container.textContent).not.toContain('Sin pozos configurados')
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
})
}) })

View File

@@ -1,191 +1,42 @@
import { useEffect, useState, useMemo, useCallback, memo, useRef } from 'react' import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import { fetchDashboardSummary, fetchAssetTelemetry } from '../api/dashboard-api' import { fetchDashboardSummary, fetchAssetTelemetry } from '../api/dashboard-api'
import { import {
Gauge, Thermometer, Droplets, Activity,
Activity, Zap, TrendingUp
TrendingUp, Database,
Eye, EyeOff, SignalHigh
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { cn } from '@/lib/utils/cn'
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer
} from 'recharts'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api' import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api'
import { listAssets, type EdgeAsset } from '@/features/admin/projects/api/wells-api' import { listAssets, type EdgeAsset } from '@/features/admin/projects/api/wells-api'
import { listDevices } from '@/features/admin/projects/api/devices-api'
const StatCard = memo(({ stat }: { stat: any }) => ( import { listVariables } from '@/features/admin/projects/api/variables-api'
<Card className="bg-card border-border"> import { getAlarms, type AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
<CardContent className="px-4 py-3"> import { DashboardOperationalHeader } from '../components/DashboardOperationalHeader'
<div className="flex items-center gap-2 mb-2"> import { DashboardEmptyState } from '../components/DashboardEmptyState'
<div className="p-1.5 rounded-md bg-secondary"> import { DashboardPanoramaOverview } from '../components/DashboardPanoramaOverview'
<stat.icon className={cn('h-3.5 w-3.5', stat.color)} /> import { DashboardPanoramaLowerSection } from '../components/DashboardPanoramaLowerSection'
</div> import { TelemetryChart } from '../components/TelemetryChart'
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">{stat.trend}</span> import { getAssetProjectId, type AssetData } from '../utils/telemetry-normalization'
</div> import {
<p className="text-xs text-muted-foreground mb-0.5">{stat.label}</p> buildAssetOptions,
<div className="flex items-baseline gap-1"> buildDynamicStats,
<span className="text-xl font-bold tabular-nums"> buildRecentAlerts,
{typeof stat.value === 'number' ? stat.value.toFixed(1) : stat.value} buildPanoramaSummary,
</span> buildVariableCategoryHealth,
<span className={cn('text-xs', stat.color)}>{stat.unit}</span> buildWellOverviewCards,
</div> countConnectedWells,
</CardContent> getComplianceStatus,
</Card> getDashboardStatus,
)) getUnitBackgroundClass,
getUnitColorClass,
const TelemetryChart = memo(({ group, gIdx, visibleVars, toggleVariable, getBgForUnit, getColorForUnit }: any) => ( groupChartData,
<Card className="bg-card border-border"> type VariableCatalogItem,
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 px-4 py-3 border-b border-border"> } from '../utils/dashboard-view-model'
<div className="flex items-center gap-3">
<div className={cn('p-1.5 rounded-md', getBgForUnit(group.unit))}>
<TrendingUp className={cn('h-4 w-4', getColorForUnit(group.unit))} />
</div>
<div>
<h3 className="text-sm font-semibold">{group.unit}</h3>
<p className="text-xs text-muted-foreground">Analisis historico</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{group.variables.map((v: string) => (
<button
key={v}
onClick={() => toggleVariable(v)}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors border',
visibleVars[v] === false
? 'bg-secondary/30 border-border text-muted-foreground opacity-50'
: 'bg-secondary border-border text-foreground'
)}
>
{visibleVars[v] === false ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{v}
</button>
))}
</div>
</div>
<div className="h-64 p-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={group.data}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="time"
stroke="rgba(255,255,255,0.15)"
fontSize={10}
tickLine={false}
axisLine={false}
minTickGap={40}
dy={8}
/>
<YAxis
stroke="rgba(255,255,255,0.15)"
fontSize={10}
tickLine={false}
axisLine={false}
dx={-8}
/>
<Tooltip
contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: '8px', padding: '10px' }}
itemStyle={{ fontSize: '12px' }}
labelStyle={{ opacity: 0.5, fontSize: '11px', marginBottom: '6px' }}
/>
{group.variables.map((v: string, vIdx: number) => (
<Line
key={v}
type="linear"
dataKey={v}
hide={visibleVars[v] === false}
stroke={`hsl(${vIdx * 80 + gIdx * 140}, 70%, 55%)`}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0, fill: '#fff' }}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</Card>
))
interface AssetData {
asset_id: string
asset: string
project_id?: string
last_updated: string
telemetry: Record<string, any>
}
function getAssetProjectId(asset: AssetData): string | null {
const directProjectId = asset.project_id
if (typeof directProjectId === 'string' && directProjectId.length > 0) return directProjectId
const telemetryProjectId = asset.telemetry?.project_id
if (typeof telemetryProjectId === 'string' && telemetryProjectId.length > 0) return telemetryProjectId
return null
}
function deduceUnit(key: string): string {
const k = key.toLowerCase();
if (k.includes('presion') || k.includes('presión') || k.includes('psi')) return 'PSI';
if (k.includes('temperatura') || k.includes('temp') || k.endsWith('_c')) return '°C';
if (k.includes('caudal') || k.includes('bpd') || k.includes('l/h')) return 'BPD';
if (k.includes('bsw') || k.includes('percent') || k.includes('%')) return '%';
if (k.includes('nivel') || k.includes('pies') || k.includes('feet')) return 'pies';
if (k.includes('volumen') || k.includes('bbl')) return 'BBL';
return 'RAW';
}
function normalizeTelemetry(telemetryData: any): Record<string, { value: number; unit: string }> {
if (!telemetryData || typeof telemetryData !== 'object') return {};
// Caso 1: Estructura con la clave "variables" (formato enriquecido)
if (telemetryData.variables && typeof telemetryData.variables === 'object') {
const result: Record<string, { value: number; unit: string }> = {};
Object.entries(telemetryData.variables).forEach(([key, info]: [string, any]) => {
result[key] = {
value: typeof info?.value === 'number' ? info.value : Number(info?.value || 0),
unit: info?.unit || 'RAW'
};
});
return result;
}
// Caso 2: Estructura con la clave "values" (formato del agente de campo)
if (telemetryData.values && typeof telemetryData.values === 'object') {
const result: Record<string, { value: number; unit: string }> = {};
Object.entries(telemetryData.values).forEach(([key, val]: [string, any]) => {
result[key] = {
value: typeof val === 'number' ? val : Number(val || 0),
unit: deduceUnit(key)
};
});
return result;
}
// Caso 3: Objeto plano sin contenedor (ej: base de datos sembrada)
const result: Record<string, { value: number; unit: string }> = {};
const ignoredKeys = ['project_id', 'device_name', 'timestamp', 'asset_id', 'asset', 'last_updated', 'telemetry', 'time', 'data', 'id'];
Object.entries(telemetryData).forEach(([key, val]) => {
if (ignoredKeys.includes(key)) return;
if (typeof val === 'number' || !isNaN(Number(val))) {
result[key] = {
value: typeof val === 'number' ? val : Number(val),
unit: deduceUnit(key)
};
}
});
return result;
}
export default function DashboardPage() { export default function DashboardPage() {
const [assets, setAssets] = useState<AssetData[]>([]) const [assets, setAssets] = useState<AssetData[]>([])
const [wellAssets, setWellAssets] = useState<EdgeAsset[]>([]) const [wellAssets, setWellAssets] = useState<EdgeAsset[]>([])
const [variableCatalog, setVariableCatalog] = useState<VariableCatalogItem[]>([])
const [alarms, setAlarms] = useState<AlarmEvent[]>([])
const [history, setHistory] = useState<any[]>([]) const [history, setHistory] = useState<any[]>([])
const [, setLoading] = useState(true) const [, setLoading] = useState(true)
const [selectedAsset, setSelectedAsset] = useState<string | 'all'>('all') const [selectedAsset, setSelectedAsset] = useState<string | 'all'>('all')
@@ -212,6 +63,11 @@ export default function DashboardPage() {
return summaryProjectIds.size === 1 ? Array.from(summaryProjectIds)[0]! : null return summaryProjectIds.size === 1 ? Array.from(summaryProjectIds)[0]! : null
}, [activeProjectId, projects, assets]) }, [activeProjectId, projects, assets])
const resolvedProjectName = useMemo(() => {
if (!resolvedProjectId) return null
return projects.find((project) => project.id === resolvedProjectId)?.name ?? null
}, [projects, resolvedProjectId])
useEffect(() => { useEffect(() => {
if (!resolvedProjectId || !token) return; if (!resolvedProjectId || !token) return;
const apiBase = import.meta.env.VITE_API_URL || ''; const apiBase = import.meta.env.VITE_API_URL || '';
@@ -295,7 +151,9 @@ export default function DashboardPage() {
useEffect(() => { useEffect(() => {
if (!resolvedProjectId) { if (!resolvedProjectId) {
setWellAssets([]) setWellAssets([])
setVariableCatalog([])
setSelectedAsset('all') setSelectedAsset('all')
setAlarms([])
return return
} }
@@ -306,12 +164,39 @@ export default function DashboardPage() {
setSelectedAsset((current) => ( setSelectedAsset((current) => (
current === 'all' || wells.some((asset) => asset.id === current) ? current : 'all' current === 'all' || wells.some((asset) => asset.id === current) ? current : 'all'
)) ))
Promise.all(
wells.map(async (well) => {
const devices = await listDevices(well.id)
const variableGroups = await Promise.all(
devices.map(async (device) => {
const variables = await listVariables(device.id)
return variables.map((variable) => ({ ...variable, assetId: well.id }))
})
)
return variableGroups.flat()
})
)
.then((variableGroups) => setVariableCatalog(variableGroups.flat()))
.catch((err) => {
console.error('Dashboard variable metadata error:', err)
setVariableCatalog([])
})
}) })
.catch((err) => { .catch((err) => {
console.error('Well assets error:', err) console.error('Well assets error:', err)
setWellAssets([]) setWellAssets([])
setVariableCatalog([])
setSelectedAsset('all') setSelectedAsset('all')
}) })
getAlarms(resolvedProjectId)
.then(setAlarms)
.catch((err) => {
console.error('Dashboard alarms error:', err)
setAlarms([])
})
}, [resolvedProjectId]) }, [resolvedProjectId])
useEffect(() => { useEffect(() => {
@@ -324,77 +209,47 @@ export default function DashboardPage() {
.catch(err => console.error('History error:', err)) .catch(err => console.error('History error:', err))
}, [selectedAsset]); }, [selectedAsset]);
const getIconForUnit = useCallback((unit?: string) => { const selectedWell = useMemo(() => {
const u = unit?.toUpperCase() || '' if (selectedAsset === 'all') return null
if (u.includes('PSI')) return Gauge return wellAssets.find((asset) => asset.id === selectedAsset) ?? null
if (u.includes('C')) return Thermometer }, [selectedAsset, wellAssets])
if (u.includes('%')) return Droplets
if (u.includes('L/H') || u.includes('BPD')) return Activity
return Zap
}, [])
const getColorForUnit = useCallback((unit?: string) => { const selectedWellLabel = useMemo(() => {
const u = unit?.toUpperCase() || '' if (!selectedWell) return null
if (u.includes('PSI')) return 'text-cyan-400' return selectedWell.name || selectedWell.code || selectedWell.id.slice(0, 8)
if (u.includes('C')) return 'text-orange-400' }, [selectedWell])
if (u.includes('%')) return 'text-blue-400'
if (u.includes('L/H') || u.includes('BPD')) return 'text-emerald-400'
return 'text-primary'
}, [])
const getBgForUnit = useCallback((unit?: string) => { const scopedWellAssets = useMemo(() => {
const u = unit?.toUpperCase() || '' if (selectedAsset === 'all') return wellAssets
if (u.includes('PSI')) return 'bg-cyan-500/10' return selectedWell ? [selectedWell] : []
if (u.includes('C')) return 'bg-orange-500/10' }, [selectedAsset, selectedWell, wellAssets])
if (u.includes('%')) return 'bg-blue-500/10'
if (u.includes('L/H') || u.includes('BPD')) return 'bg-emerald-500/10'
return 'bg-primary/10'
}, [])
const activeAssetData = useMemo(() => { const scopedAssets = useMemo(() => {
if (selectedAsset === 'all') return assets[0] || null if (selectedAsset === 'all') return assets
return assets.find(a => a.asset_id === selectedAsset) || null return assets.filter((asset) => asset.asset_id === selectedAsset)
}, [assets, selectedAsset]) }, [assets, selectedAsset])
const scopedAlarms = useMemo(() => {
if (selectedAsset === 'all') return alarms
return alarms.filter((alarm) => alarm.asset_id === selectedAsset)
}, [alarms, selectedAsset])
const scopedVariableCatalog = useMemo(() => {
if (selectedAsset === 'all') return variableCatalog
return variableCatalog.filter((variable) => variable.assetId === selectedAsset)
}, [selectedAsset, variableCatalog])
const activeAssetData = useMemo(() => {
if (selectedAsset === 'all') return null
return scopedAssets[0] || null
}, [scopedAssets, selectedAsset])
const dynamicStats = useMemo(() => { const dynamicStats = useMemo(() => {
if (!activeAssetData) return [] return buildDynamicStats(activeAssetData)
const rawData = activeAssetData.telemetry }, [activeAssetData])
const normalized = normalizeTelemetry(rawData)
const variables: any[] = []
Object.entries(normalized).forEach(([key, info]: [string, any]) => {
variables.push({
label: key, value: info?.value ?? 0, unit: info?.unit || '',
icon: getIconForUnit(info.unit), color: getColorForUnit(info.unit),
bg: getBgForUnit(info.unit), trend: 'Realtime'
})
})
return variables
}, [activeAssetData, getIconForUnit, getColorForUnit, getBgForUnit])
const chartGroupedData = useMemo(() => { const chartGroupedData = useMemo(() => {
if (history.length === 0) return [] return groupChartData(history, selectedAsset)
const groups: Record<string, any[]> = {}
const filteredHistory = history.filter(record => {
if (selectedAsset === 'all') return true;
const assetId = record.asset_id || record.data?.asset_id || record.data?.id;
return !assetId || assetId === selectedAsset;
});
const displayData = [...filteredHistory].reverse();
displayData.forEach((record) => {
const timestamp = new Date(record.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
const normalized = normalizeTelemetry(record.data)
Object.entries(normalized).forEach(([key, info]: [string, any]) => {
const unit = info.unit || 'RAW'
if (!groups[unit]) groups[unit] = []
let entry = groups[unit].find(e => e.time === timestamp)
if (!entry) { entry = { time: timestamp }; groups[unit].push(entry) }
entry[key] = info.value
})
})
return Object.entries(groups).map(([unit, data]) => {
const keys = Object.keys(data[0] || {}).filter(k => k !== 'time')
return { unit, data, variables: keys }
})
}, [history, selectedAsset]) }, [history, selectedAsset])
const toggleVariable = useCallback((varName: string) => { const toggleVariable = useCallback((varName: string) => {
@@ -402,94 +257,150 @@ export default function DashboardPage() {
}, []) }, [])
const complianceStatus = useMemo(() => { const complianceStatus = useMemo(() => {
const startOfToday = new Date(); return getComplianceStatus(history, selectedAsset)
startOfToday.setHours(0, 0, 0, 0);
const assetHistory = history.filter(h => {
if (selectedAsset === 'all') return true;
const assetId = h.asset_id || h.data?.asset_id || h.data?.id;
return !assetId || assetId === selectedAsset;
})
const samplesTodayCount = assetHistory.filter(h => new Date(h.time) >= startOfToday).length
const lastUpdate = assetHistory.length > 0 ? new Date(assetHistory[0].time) : null
const diffSeconds = lastUpdate ? (new Date().getTime() - lastUpdate.getTime()) / 1000 : 9999
return { samplesToday: samplesTodayCount, isStable: diffSeconds < 30, latencyMs: lastUpdate ? Math.floor(Math.random() * 40) + 10 : 0 }
}, [history, selectedAsset]) }, [history, selectedAsset])
const status = useMemo(() => { const status = useMemo(() => {
if (!isConnected) return { color: 'text-red-500', label: 'Desconectado' } return getDashboardStatus(isConnected, complianceStatus.isStable)
if (!complianceStatus.isStable) return { color: 'text-amber-500', label: 'Señal retrasada' }
return { color: 'text-emerald-500', label: 'Operacional' }
}, [isConnected, complianceStatus.isStable]) }, [isConnected, complianceStatus.isStable])
const assetOptions = useMemo(() => [ const assetOptions = useMemo(() => buildAssetOptions(wellAssets), [wellAssets])
{ value: 'all', label: 'Todos los pozos' }, const connectedWellCount = useMemo(() => (
...wellAssets.map((asset) => { countConnectedWells(scopedAssets, scopedWellAssets)
const primaryLabel = asset.name || asset.code || asset.id.slice(0, 8) ), [scopedAssets, scopedWellAssets])
const label = asset.code && asset.name ? `${primaryLabel} (${asset.code})` : primaryLabel const wellOverviewCards = useMemo(() => (
buildWellOverviewCards(scopedWellAssets, scopedAssets, scopedAlarms, history, selectedAsset, resolvedProjectName)
), [scopedWellAssets, scopedAssets, scopedAlarms, history, selectedAsset, resolvedProjectName])
const monitoredVariableCount = useMemo(() => (
wellOverviewCards.reduce((total, well) => total + well.variablesCount, 0)
), [wellOverviewCards])
const panoramaSummary = useMemo(() => (
buildPanoramaSummary(scopedAssets, scopedWellAssets, scopedAlarms, monitoredVariableCount)
), [scopedAssets, scopedWellAssets, scopedAlarms, monitoredVariableCount])
const variableCategoryHealth = useMemo(() => (
buildVariableCategoryHealth(scopedVariableCatalog, scopedAssets, scopedAlarms, scopedWellAssets)
), [scopedVariableCatalog, scopedAssets, scopedAlarms, scopedWellAssets])
const recentAlerts = useMemo(() => (
buildRecentAlerts(scopedAlarms, scopedWellAssets, resolvedProjectName)
), [scopedAlarms, scopedWellAssets, resolvedProjectName])
return { value: asset.id, label } const lowerSectionLabels = useMemo(() => {
}), if (selectedAsset === 'all') {
], [wellAssets]) return {
overviewTitle: 'Vista general por pozo',
overviewEmptyTitle: 'Sin pozos cargados',
overviewEmptyDescription: 'No hay activos POZO disponibles para el proyecto seleccionado.',
alertsTitle: 'Alertas recientes',
alertsEmptyTitle: 'Sin alertas recientes',
alertsEmptyDescription: 'No hay alarmas reales para mostrar en este momento.',
}
}
const label = selectedWellLabel ?? 'pozo seleccionado'
return {
overviewTitle: `Resumen de ${label}`,
overviewEmptyTitle: 'Sin datos del pozo seleccionado',
overviewEmptyDescription: 'No se encontró el activo POZO seleccionado en el proyecto actual.',
alertsTitle: `Alertas de ${label}`,
alertsEmptyTitle: 'Sin alertas del pozo seleccionado',
alertsEmptyDescription: 'No hay alarmas reales para este pozo en este momento.',
}
}, [selectedAsset, selectedWellLabel])
return ( return (
<div className="flex flex-col gap-6 pb-8"> <div className="flex flex-col gap-6 pb-8">
{/* Header */} <DashboardOperationalHeader
<div className="relative z-40 flex flex-col md:flex-row md:items-center justify-between gap-4"> status={status}
<div> complianceStatus={complianceStatus}
<div className="flex items-center gap-2 mb-1"> assetOptions={assetOptions}
<h1 className="text-xl font-semibold">Panorama</h1> connectedWellCount={connectedWellCount}
<Badge variant="outline" className={cn('text-xs', status.color)}> selectedAsset={selectedAsset}
{status.label} setSelectedAsset={setSelectedAsset}
</Badge> hasResolvedProject={Boolean(resolvedProjectId)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Database className="h-3 w-3" />
{complianceStatus.samplesToday} muestras hoy
</span>
<span className="flex items-center gap-1">
<SignalHigh className="h-3 w-3" />
Latencia {complianceStatus.latencyMs}ms
</span>
</div>
</div>
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-card/70 px-3 py-2 shadow-lg shadow-black/10 backdrop-blur-sm">
<label className="text-[10px] font-semibold uppercase tracking-[0.18em] text-primary/80">Activo</label>
<Select
aria-label="Activo"
className="min-w-40"
options={assetOptions}
value={selectedAsset}
onValueChange={setSelectedAsset}
/> />
</div>
</div>
{/* KPI Grid */} <DashboardPanoramaOverview
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4"> status={status}
{dynamicStats.length === 0 ? ( complianceStatus={complianceStatus}
<div className="col-span-full py-12 text-center border border-dashed border-border rounded-lg"> connectedWellCount={panoramaSummary.connectedWellCount}
<TrendingUp className="h-6 w-6 mx-auto mb-2 text-muted-foreground" /> signalCount={panoramaSummary.signalCount}
<p className="text-sm text-muted-foreground">Sincronizando sensores...</p> metrics={panoramaSummary.metrics}
/>
<DashboardPanoramaLowerSection
wells={wellOverviewCards}
alerts={recentAlerts}
categoryHealth={variableCategoryHealth}
{...lowerSectionLabels}
/>
<section aria-label="Estado de telemetría SCADA" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20 backdrop-blur">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Panel SCADA</p>
<h2 className="text-sm font-semibold text-foreground">Estado de sensores</h2>
</div> </div>
<span className="text-[10px] uppercase tracking-[0.18em] text-[#00b7ff]">Datos en vivo</span>
</div>
<div>
{dynamicStats.length === 0 ? (
<DashboardEmptyState
icon={TrendingUp}
label="Estado SCADA"
title="Sin señal de sensores"
description="Esperando el primer paquete de telemetría del pozo seleccionado."
/>
) : ( ) : (
dynamicStats.map((stat) => ( <div className="rounded-[1.5rem] border border-[#2a3038] bg-[#171b20] p-4 shadow-sm shadow-black/10">
<StatCard key={stat.label} stat={stat} /> <div className="mb-4 flex items-center justify-between gap-3">
)) <div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Estado SCADA</p>
<h3 className="text-base font-semibold text-foreground">Señal de sensores activa</h3>
</div>
<span className="text-[10px] uppercase tracking-[0.18em] text-[#00b7ff]">{dynamicStats.length} variables</span>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{dynamicStats.map((stat) => {
const formattedValue = typeof stat.value === 'number' ? stat.value.toFixed(1) : stat.value
return (
<div key={stat.label} aria-label={`${stat.label}: ${formattedValue} ${stat.unit}`} className="rounded-2xl border border-[#2a3038] bg-[#15191e] p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{stat.label}</span>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-semibold tabular-nums text-foreground">{formattedValue}</span>
<span className={`text-xs font-semibold uppercase tracking-wide ${stat.color}`}>{stat.unit}</span>
</div>
</div>
)
})}
</div>
</div>
)} )}
</div> </div>
</section>
{/* Charts */} <section aria-label="Serie histórica SCADA" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20 backdrop-blur">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Histórico SCADA</p>
<h2 className="text-sm font-semibold text-foreground">Serie histórica</h2>
</div>
<span className="text-[10px] uppercase tracking-[0.18em] text-[#00b7ff]">Series por unidad</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{chartGroupedData.length === 0 ? ( {chartGroupedData.length === 0 ? (
<Card className="col-span-full border-border"> <div className="col-span-full">
<CardContent className="flex items-center justify-center h-64"> <DashboardEmptyState
<div className="text-center text-muted-foreground"> icon={Activity}
<Activity className="h-8 w-8 mx-auto mb-2" /> label="Serie SCADA"
<p className="text-sm">Esperando datos de telemetria...</p> title="Telemetría en espera"
description="La serie histórica aparecerá cuando ingresen muestras válidas."
/>
</div> </div>
</CardContent>
</Card>
) : ( ) : (
chartGroupedData.map((group, gIdx) => ( chartGroupedData.map((group, gIdx) => (
<TelemetryChart <TelemetryChart
@@ -498,12 +409,13 @@ export default function DashboardPage() {
gIdx={gIdx} gIdx={gIdx}
visibleVars={visibleVars} visibleVars={visibleVars}
toggleVariable={toggleVariable} toggleVariable={toggleVariable}
getBgForUnit={getBgForUnit} getBgForUnit={getUnitBackgroundClass}
getColorForUnit={getColorForUnit} getColorForUnit={getUnitColorClass}
/> />
)) ))
)} )}
</div> </div>
</section>
</div> </div>
) )
} }

View File

@@ -0,0 +1,87 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import RealtimePage from './RealtimePage'
vi.mock('./DashboardPage', () => ({
default: () => <div>Dashboard operativo</div>,
}))
vi.mock('./VariablesView', () => ({
VariablesView: () => <div>Explorador de variables</div>,
}))
vi.mock('./TrendsView', () => ({
TrendsView: () => <div>Historial de variables</div>,
}))
describe('RealtimePage', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
afterEach(async () => {
if (root) {
await act(async () => root.unmount())
}
document.body.innerHTML = ''
window.history.pushState({}, '', '/')
})
function renderAt(path: string) {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root.render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/app/admin/realtime" element={<RealtimePage />} />
</Routes>
</MemoryRouter>
))
}
it('renders variables view for the variables search param', () => {
renderAt('/app/admin/realtime?view=variables')
expect(container.textContent).toContain('Explorador de variables')
})
it('renders trends view for the trends search param', () => {
renderAt('/app/admin/realtime?view=trends')
expect(container.textContent).toContain('Historial de variables')
})
it('keeps dashboard behavior for the default realtime route', () => {
renderAt('/app/admin/realtime')
expect(container.textContent).toContain('Dashboard operativo')
})
it('switches views when only the search param changes', async () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root.render(
<MemoryRouter initialEntries={['/app/admin/realtime?view=variables']}>
<Link to="/app/admin/realtime?view=trends">Ir a tendencias</Link>
<Routes>
<Route path="/app/admin/realtime" element={<RealtimePage />} />
</Routes>
</MemoryRouter>
)
})
expect(container.textContent).toContain('Explorador de variables')
await act(async () => {
container.querySelector<HTMLAnchorElement>('a')?.click()
})
expect(container.textContent).toContain('Historial de variables')
expect(container.textContent).not.toContain('Explorador de variables')
})
})

View File

@@ -0,0 +1,15 @@
import { useLocation } from 'react-router-dom'
import DashboardPage from './DashboardPage'
import { TrendsView } from './TrendsView'
import { VariablesView } from './VariablesView'
export default function RealtimePage() {
const location = useLocation()
const view = new URLSearchParams(location.search).get('view')
if (view === 'variables') return <VariablesView />
if (view === 'trends') return <TrendsView />
return <DashboardPage />
}

View File

@@ -0,0 +1,214 @@
import { act } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import { fetchAssetTelemetryHistory } from '../api/dashboard-api'
import { TrendsView } from './TrendsView'
vi.mock('@/features/admin/projects/api/projects-api', () => ({
getProjectFullConfig: vi.fn(),
}))
vi.mock('../api/dashboard-api', () => ({
fetchAssetTelemetryHistory: vi.fn(),
}))
vi.mock('recharts', () => {
function ChartShell({ children }: { children?: ReactNode }) {
return <div>{children ? null : null}</div>
}
function ChartLine({ name }: { name?: string }) {
return <div>{name}</div>
}
return {
Area: () => null,
AreaChart: ChartShell,
CartesianGrid: () => null,
Legend: () => <div>Legend</div>,
Line: ChartLine,
LineChart: ChartShell,
ResponsiveContainer: ChartShell,
Tooltip: () => null,
XAxis: () => null,
YAxis: () => null,
}
})
describe('TrendsView', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(async () => {
if (root) {
await act(async () => root.unmount())
}
vi.clearAllTimers()
vi.useRealTimers()
document.body.innerHTML = ''
localStorage.clear()
useAuthStore.setState({ activeProjectId: null, projects: [], token: null, accessToken: null })
vi.clearAllMocks()
})
async function renderView() {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => root.render(<TrendsView />))
}
async function flushEffects() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
function mockConfig() {
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: {
id: 'project-1',
name: 'Proyecto Uno',
client: 'Cliente',
operator_name: 'Operador',
contract_number: 'C-1',
is_active: true,
created_at: '2026-06-01T00:00:00Z',
},
assets: [{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
devices: [{
device: {
id: 'device-1',
asset_id: 'well-1',
name: 'PLC Norte',
protocol: 'MODBUS_TCP',
host: '127.0.0.1',
port: 502,
protocol_config: {},
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
variables: [
{
id: 'var-1',
device_id: 'device-1',
address: '40001',
data_type: 'float32',
alias: 'PRESION_CABEZA',
unit: 'psi',
polling_interval_ms: 5000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: { label: 'Presión de cabeza', category: 'Presión' },
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
{
id: 'var-2',
device_id: 'device-1',
address: '40003',
data_type: 'float32',
alias: 'TEMP_MOTOR',
unit: '°C',
polling_interval_ms: 5000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: { label: 'Temperatura motor', category: 'Temperatura' },
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
],
}],
}],
})
}
it('shows an honest empty state when there is no active project', async () => {
await renderView()
expect(container.textContent).toContain('Sin proyecto activo')
expect(getProjectFullConfig).not.toHaveBeenCalled()
})
it('shows an honest empty state when the project has no POZO variables', async () => {
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: {
id: 'project-1',
name: 'Proyecto Uno',
client: 'Cliente',
operator_name: 'Operador',
contract_number: 'C-1',
is_active: true,
created_at: '2026-06-01T00:00:00Z',
},
assets: [],
})
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
await renderView()
await act(async () => {})
expect(container.textContent).toContain('Sin pozos con variables')
})
it('defaults to multiple variables and renders real series controls', async () => {
mockConfig()
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
records: [
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102, TEMP_MOTOR: 56 } } },
],
})
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
await renderView()
await flushEffects()
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 12)
expect(container.textContent).toContain('Tendencias históricas')
expect(container.textContent).toContain('Comparativo de variables')
expect(container.textContent).toContain('Presión de cabeza')
expect(container.textContent).toContain('Temperatura motor')
expect(container.textContent).toContain('+1% vs muestra previa')
expect(container.textContent).toContain('+1.8% vs muestra previa')
})
it('shows an honest one-point hint without fabricating a curve', async () => {
mockConfig()
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({
records: [
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
],
})
useAuthStore.setState({ activeProjectId: 'project-1', projects: [{ id: 'project-1', name: 'Proyecto Uno' }] })
await renderView()
await flushEffects()
expect(container.textContent).toContain('Más muestras para tendencia')
expect(container.textContent).toContain('Muestras visibles')
})
})

View File

@@ -0,0 +1,359 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Activity, TrendingUp } from 'lucide-react'
import { Area, AreaChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { fetchAssetTelemetryHistory, type TelemetryHistoryResponse } from '../api/dashboard-api'
import { buildTrendMultiSeries, buildTrendWellOptions, type TrendMultiSeries } from '../utils/trends-view-model'
import { getProjectFullConfig, type EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import { useAuthStore } from '@/features/auth/stores/auth-store'
const rangeOptions = [
{ label: '1h', days: 1, limit: 12 },
{ label: '24h', days: 1, limit: 288 },
{ label: '7d', days: 7, limit: 2016 },
{ label: '30d', days: 30, limit: 2016 },
]
export function TrendsView() {
const activeProjectId = useAuthStore((state) => state.activeProjectId)
const projects = useAuthStore((state) => state.projects)
const [config, setConfig] = useState<EdgeProjectFullConfig | null>(null)
const [history, setHistory] = useState<TelemetryHistoryResponse | null>(null)
const [selectedAssetId, setSelectedAssetId] = useState('')
const [selectedAliases, setSelectedAliases] = useState<string[]>([])
const [rangeIndex, setRangeIndex] = useState(0)
const [isConfigLoading, setIsConfigLoading] = useState(true)
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const defaultedAssetIdRef = useRef('')
const resolvedProjectId = useMemo(() => {
if (activeProjectId) return activeProjectId
return projects.length === 1 ? projects[0]?.id ?? null : null
}, [activeProjectId, projects])
useEffect(() => {
let mounted = true
async function loadConfig() {
if (!resolvedProjectId) {
setConfig(null)
setSelectedAssetId('')
setSelectedAliases([])
setIsConfigLoading(false)
return
}
setIsConfigLoading(true)
setError(null)
try {
const fullConfig = await getProjectFullConfig(resolvedProjectId)
if (!mounted) return
setConfig(fullConfig)
} catch (loadError) {
if (!mounted) return
console.error('Trends config error:', loadError)
setConfig(null)
setSelectedAssetId('')
setSelectedAliases([])
setError('No se pudo cargar la configuración real de pozos y variables.')
} finally {
if (mounted) setIsConfigLoading(false)
}
}
loadConfig()
return () => { mounted = false }
}, [resolvedProjectId])
const wells = useMemo(() => buildTrendWellOptions(config), [config])
const selectedWell = useMemo(() => wells.find((well) => well.id === selectedAssetId) ?? wells[0] ?? null, [selectedAssetId, wells])
const selectedVariables = useMemo(() => {
if (!selectedWell) return []
return selectedWell.variables.filter((variable) => selectedAliases.includes(variable.alias))
}, [selectedAliases, selectedWell])
const selectedRange = rangeOptions[rangeIndex] ?? rangeOptions[0]
useEffect(() => {
if (!selectedWell) {
if (selectedAssetId) setSelectedAssetId('')
if (selectedAliases.length > 0) setSelectedAliases([])
return
}
if (selectedAssetId !== selectedWell.id) setSelectedAssetId(selectedWell.id)
const aliases = selectedWell.variables.map((variable) => variable.alias)
if (defaultedAssetIdRef.current !== selectedWell.id) {
defaultedAssetIdRef.current = selectedWell.id
setSelectedAliases(aliases)
return
}
const validSelectedAliases = selectedAliases.filter((alias) => aliases.includes(alias))
if (validSelectedAliases.length !== selectedAliases.length) {
setSelectedAliases(validSelectedAliases)
}
}, [selectedAliases, selectedAssetId, selectedWell])
useEffect(() => {
let mounted = true
async function loadHistory(showLoading: boolean) {
if (!selectedWell || selectedWell.variables.length === 0) {
setHistory(null)
return
}
if (showLoading) setIsHistoryLoading(true)
setError(null)
try {
const nextHistory = await fetchAssetTelemetryHistory(selectedWell.id, selectedRange.days, selectedRange.limit)
if (mounted) setHistory(nextHistory)
} catch (loadError) {
if (!mounted) return
console.error('Trends history error:', loadError)
setHistory(null)
setError('No se pudo cargar el historial real del pozo seleccionado.')
} finally {
if (mounted) setIsHistoryLoading(false)
}
}
loadHistory(true)
const refreshTimer = window.setInterval(() => { void loadHistory(false) }, 5000)
return () => {
mounted = false
window.clearInterval(refreshTimer)
}
}, [selectedRange.days, selectedRange.limit, selectedWell])
const multiSeries = useMemo(() => buildTrendMultiSeries(history, selectedVariables), [history, selectedVariables])
const variableCards = multiSeries.series
const seriesWithSamples = variableCards.filter((serie) => serie.points.length > 0)
const totalSamples = variableCards.reduce((sum, serie) => sum + serie.points.length, 0)
const hasOnePointSeries = variableCards.some((serie) => serie.points.length === 1)
const allVariablesSelected = selectedWell ? selectedAliases.length === selectedWell.variables.length : false
function toggleAlias(alias: string) {
setSelectedAliases((current) => {
if (current.includes(alias)) return current.filter((selectedAlias) => selectedAlias !== alias)
return [...current, alias]
})
}
function selectAllVariables() {
setSelectedAliases(selectedWell?.variables.map((variable) => variable.alias) ?? [])
}
return (
<div className="relative min-h-screen overflow-hidden rounded-[2rem] border border-[#242a31] bg-[#090b0f] p-4 text-slate-100 shadow-2xl shadow-black/40 md:p-6">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,122,26,0.06),transparent_32%),linear-gradient(180deg,rgba(20,24,29,0.72),rgba(9,11,15,0.96))]" />
<div className="relative mb-6 flex flex-col gap-4 border-b border-emerald-400/10 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100">
<TrendingUp className="h-3.5 w-3.5" /> Tendencias reales
</div>
<h1 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">Tendencias históricas</h1>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
Análisis comparativo de variables por pozo.
</p>
</div>
<div className="grid gap-3 rounded-2xl border border-[#2a3038] bg-[#11151b]/90 p-3 shadow-lg shadow-black/20 sm:grid-cols-[minmax(11rem,1fr)_auto] lg:min-w-[31rem]">
<label className="flex flex-col gap-1 text-xs font-medium text-slate-400">
Pozo
<select value={selectedWell?.id ?? ''} onChange={(event) => { setSelectedAssetId(event.target.value); setSelectedAliases([]) }} className="rounded-xl border border-[#2a3038] bg-[#0b0f14] px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-300">
{wells.map((well) => <option key={well.id} value={well.id}>{well.name}</option>)}
</select>
</label>
<div className="flex flex-col gap-1 text-xs font-medium text-slate-400">
Rango
<div className="grid grid-cols-4 gap-1 rounded-xl border border-[#2a3038] bg-[#0b0f14] p-1">
{rangeOptions.map((option, index) => (
<button
key={option.label}
type="button"
onClick={() => setRangeIndex(index)}
className={`rounded-lg px-2 py-1.5 text-xs font-semibold transition ${rangeIndex === index ? 'bg-emerald-400 text-slate-950' : 'text-slate-400 hover:bg-slate-800 hover:text-white'}`}
aria-pressed={rangeIndex === index}
>
{option.label}
</button>
))}
</div>
</div>
<div className="sm:col-span-2 flex items-center justify-between gap-3 rounded-xl border border-[#2a3038] bg-[#0b0f14]/80 px-3 py-2 text-xs">
<span className="text-slate-500">Muestras visibles</span>
<span className="font-mono font-semibold text-white">{totalSamples}</span>
<span className="text-emerald-200">Actualiza cada 5 s</span>
</div>
</div>
</div>
<section aria-label="Variables de tendencias" className="relative mb-5 rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/85 p-3 shadow-xl shadow-black/20">
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-slate-400">Variables</p>
<button type="button" onClick={selectAllVariables} className="rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-[11px] font-semibold text-orange-100 transition hover:border-orange-300/40" disabled={allVariablesSelected}>
Todas las variables
</button>
</div>
<div className="flex flex-wrap gap-2">
{(selectedWell?.variables ?? []).map((variable) => {
const isSelected = selectedAliases.includes(variable.alias)
return (
<button
key={variable.id}
type="button"
onClick={() => toggleAlias(variable.alias)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold transition ${isSelected ? 'border-cyan-300/50 bg-cyan-300/15 text-cyan-50' : 'border-[#2a3038] bg-[#0b0f14] text-slate-400 hover:border-slate-600'}`}
aria-pressed={isSelected}
>
{variable.label}
</button>
)
})}
</div>
</section>
{isConfigLoading ? (
<TrendsMessage title="Cargando tendencias reales" description="Consultando configuración de pozos y variables del proyecto." />
) : error ? (
<TrendsMessage icon={<AlertTriangle className="h-5 w-5 text-amber-300" />} title="No se pudo cargar la vista" description={error} />
) : !resolvedProjectId ? (
<TrendsMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver tendencias reales." />
) : wells.length === 0 ? (
<TrendsMessage title="Sin pozos con variables" description="No hay variables configuradas en assets de tipo POZO para este proyecto." />
) : selectedVariables.length === 0 ? (
<TrendsMessage title="Sin variables seleccionadas" description="Seleccioná una o más variables para consultar el historial real." />
) : isHistoryLoading ? (
<TrendsMessage title="Cargando historial real" description="Consultando muestras históricas del pozo seleccionado." />
) : !history || (history.records ?? []).length === 0 ? (
<TrendsMessage title="Sin historial" description="El backend no devolvió muestras históricas para el pozo seleccionado." />
) : seriesWithSamples.length === 0 ? (
<TrendsMessage title="Sin muestras para las variables" description="El historial existe, pero no contiene valores numéricos para las variables seleccionadas." />
) : (
<section aria-label={`Comparativo de variables de ${selectedWell.name}`} className="relative">
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-emerald-200">{selectedWell.name}</p>
<h2 className="text-base font-semibold text-white">Comparativo de variables</h2>
<p className="text-xs text-slate-500">{seriesWithSamples.length} de {variableCards.length} variables con muestras reales · {selectedRange.label}</p>
</div>
{hasOnePointSeries ? <span className="rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-xs font-semibold text-orange-100">Más muestras para tendencia</span> : null}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{variableCards.map((series) => <TrendVariableCard key={series.alias} series={series} />)}
</div>
</section>
)}
</div>
)
}
function formatValue(value: number | null | undefined, unit?: string) {
if (value === null || value === undefined) return 'Sin dato'
const formatted = Number(value.toFixed(3)).toString()
return unit ? `${formatted} ${unit}` : formatted
}
function formatFrequency(pollingIntervalMs?: number) {
if (!pollingIntervalMs) return 'frecuencia no configurada'
if (pollingIntervalMs < 60_000) return `cada ${Math.round(pollingIntervalMs / 1000)} s`
return `cada ${Math.round(pollingIntervalMs / 60_000)} min`
}
function buildDelta(points: TrendMultiSeries['points']) {
if (points.length < 2) return null
const latest = points.at(-1)?.value
const previous = points.at(-2)?.value
if (latest === undefined || previous === undefined || previous === 0) return null
return ((latest - previous) / Math.abs(previous)) * 100
}
function TrendVariableCard({ series }: { series: TrendMultiSeries }) {
const values = series.points.map((point) => point.value)
const latest = series.points.at(-1)?.value
const min = values.length ? Math.min(...values) : null
const max = values.length ? Math.max(...values) : null
const delta = buildDelta(series.points)
return (
<article className="overflow-hidden rounded-[1.5rem] border border-[#252b33] bg-[#11151b]/90 shadow-xl shadow-black/25">
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-base font-semibold text-white">{series.label}</h3>
<p className="mt-1 text-xs text-slate-500">{series.category} · {formatFrequency(series.pollingIntervalMs)}</p>
</div>
<div className="text-left sm:text-right">
<p className="font-mono text-2xl font-semibold tabular-nums text-white">{formatValue(latest, series.unit)}</p>
{delta === null ? (
<p className="mt-1 text-xs font-medium text-slate-500">Más muestras para tendencia</p>
) : (
<p className={`mt-1 text-xs font-semibold ${delta >= 0 ? 'text-emerald-300' : 'text-red-300'}`}>
{delta >= 0 ? '+' : ''}{formatPercent(delta)} vs muestra previa
</p>
)}
</div>
</div>
<div className="mx-4 grid grid-cols-3 gap-2 border-y border-[#252b33] py-3 text-xs">
<TrendMiniStat label="Mín" value={formatValue(min, series.unit)} />
<TrendMiniStat label="Máx" value={formatValue(max, series.unit)} />
<TrendMiniStat label="Muestras" value={series.points.length.toString()} />
</div>
<div className="h-40 bg-[#0b0f14] p-3">
{series.points.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-emerald-300/10 text-sm text-slate-500">Sin muestras numéricas reales</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={series.points}>
<defs>
<linearGradient id={`trend-fill-${series.alias}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={series.color} stopOpacity={0.45} />
<stop offset="95%" stopColor={series.color} stopOpacity={0.03} />
</linearGradient>
</defs>
<XAxis dataKey="label" stroke="rgba(209, 250, 229, 0.36)" fontSize={10} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis stroke="rgba(209, 250, 229, 0.28)" fontSize={10} tickLine={false} axisLine={false} width={42} />
<Tooltip contentStyle={{ backgroundColor: '#0b0f14', border: '1px solid rgba(42, 48, 56, 0.95)', borderRadius: '12px', color: '#d1fae5' }} formatter={(value) => [formatValue(Number(value), series.unit), series.label]} labelStyle={{ color: '#a7f3d0' }} />
<Area type="linear" dataKey="value" stroke={series.color} fill={`url(#trend-fill-${series.alias})`} strokeWidth={2.5} dot={series.points.length === 1 ? { r: 4, fill: series.color } : false} activeDot={{ r: 5, fill: '#ecfdf5' }} isAnimationActive={false} />
<Line type="linear" dataKey="value" stroke={series.color} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</article>
)
}
function formatPercent(value: number) {
return `${Number(value.toFixed(1)).toString()}%`
}
function TrendMiniStat({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-600">{label}</p>
<p className="mt-1 font-mono font-semibold text-white">{value}</p>
</div>
)
}
function TrendsMessage({ title, description, icon }: { title: string; description: string; icon?: ReactNode }) {
return (
<div className="flex min-h-64 items-center justify-center rounded-[1.5rem] border border-dashed border-slate-800 bg-slate-950/50 p-8 text-center">
<div className="max-w-md">
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-full border border-slate-800 bg-slate-900 text-slate-400">
{icon ?? <Activity className="h-5 w-5" />}
</div>
<h2 className="text-base font-semibold text-white">{title}</h2>
<p className="mt-2 text-sm text-slate-400">{description}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,225 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
import { fetchAssetTelemetryHistory, fetchDashboardSummary } from '../api/dashboard-api'
import { VariablesView } from './VariablesView'
vi.mock('@/features/admin/projects/api/projects-api', () => ({
getProjectFullConfig: vi.fn(),
}))
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
getAlarms: vi.fn(),
}))
vi.mock('../api/dashboard-api', () => ({
fetchDashboardSummary: vi.fn(),
fetchAssetTelemetryHistory: vi.fn(),
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
function projectConfigWithVariables() {
return {
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
devices: [{
device: { id: 'device-1', asset_id: 'well-1', name: 'PLC Norte', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
variables: [
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'PRESION_CABEZA', unit: 'psi', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Presión de cabeza', category: 'Presión' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-2', device_id: 'device-1', address: '40003', data_type: 'float32', alias: 'TEMP_MOTOR', unit: '°C', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Temperatura motor', category: 'Temperatura' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
],
}],
}],
}
}
function emptyProjectConfig() {
return {
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [],
}
}
function projectConfigWithVariablesForProject(projectId: string, wellId: string, label: string) {
return {
project: { id: projectId, name: `Proyecto ${projectId}`, client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [{
asset: {
id: wellId,
project_id: projectId,
code: 'PZ-01',
name: `Pozo ${projectId}`,
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
devices: [{
device: { id: `${projectId}-device-1`, asset_id: wellId, name: `PLC ${projectId}`, protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
variables: [
{ id: `${projectId}-var-1`, device_id: `${projectId}-device-1`, address: '40001', data_type: 'float32', alias: `${projectId}_PRESION`, unit: 'psi', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label, category: 'Presión' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
],
}],
}],
}
}
async function flushEffects() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
describe('VariablesView', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
vi.useFakeTimers()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
useAuthStore.setState({
token: 'token-1',
accessToken: 'token-1',
activeProjectId: 'project-1',
projects: [{ id: 'project-1', name: 'Bloque Norte' }],
})
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { variables: { PRESION_CABEZA: { value: 120, quality: 'GOOD' } } } }],
})
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [] })
vi.mocked(getAlarms).mockResolvedValue([])
})
afterEach(async () => {
await act(async () => root.unmount())
vi.clearAllTimers()
vi.useRealTimers()
document.body.innerHTML = ''
window.localStorage.clear()
useAuthStore.setState({ token: null, accessToken: null, activeProjectId: null, projects: [] })
vi.clearAllMocks()
})
async function renderView() {
await act(async () => root.render(<VariablesView />))
}
it('shows initial loading instead of an empty state while the real project fetch is pending', async () => {
const pendingConfig = deferred<ReturnType<typeof projectConfigWithVariables>>()
vi.mocked(getProjectFullConfig).mockReturnValue(pendingConfig.promise)
await renderView()
expect(container.textContent).toContain('Cargando variables reales')
expect(container.textContent).not.toContain('Sin variables reales')
expect(container.textContent).not.toContain('0 de 0 variables')
})
it('keeps previously loaded real variables visible during background refresh and refresh errors', async () => {
vi.mocked(getProjectFullConfig).mockResolvedValueOnce(projectConfigWithVariables())
const refreshConfig = deferred<ReturnType<typeof projectConfigWithVariables>>()
vi.mocked(getProjectFullConfig).mockReturnValueOnce(refreshConfig.promise)
await renderView()
await flushEffects()
expect(container.textContent).toContain('2 de 2 variables')
expect(container.textContent).toContain('Presión de cabeza')
await act(async () => {
vi.advanceTimersByTime(5000)
})
expect(container.textContent).toContain('2 de 2 variables')
expect(container.textContent).toContain('Presión de cabeza')
expect(container.textContent).not.toContain('Sin variables reales')
await act(async () => {
refreshConfig.reject(new Error('network down'))
})
await flushEffects()
expect(container.textContent).toContain('2 de 2 variables')
expect(container.textContent).toContain('Presión de cabeza')
expect(container.textContent).toContain('Se conserva el último catálogo real cargado')
expect(container.textContent).not.toContain('0 de 0 variables')
})
it('shows the confirmed empty state only after a successful real-data response has zero variables', async () => {
vi.mocked(getProjectFullConfig).mockResolvedValue(emptyProjectConfig())
await renderView()
await flushEffects()
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
expect(container.textContent).toContain('0 de 0 variables')
expect(container.textContent).toContain('Sin variables reales')
})
it('does not generate fallback variables when the real configuration is empty', async () => {
vi.mocked(getProjectFullConfig).mockResolvedValue(emptyProjectConfig())
await renderView()
await flushEffects()
expect(container.querySelectorAll('tbody tr')).toHaveLength(0)
expect(container.textContent).not.toContain('PRESION_CABEZA')
expect(fetchAssetTelemetryHistory).not.toHaveBeenCalled()
})
it('does not leak previously loaded variables while another project is loading', async () => {
vi.mocked(getProjectFullConfig).mockResolvedValueOnce(
projectConfigWithVariablesForProject('project-1', 'well-1', 'Variable proyecto 1')
)
const pendingProjectTwo = deferred<ReturnType<typeof projectConfigWithVariables>>()
vi.mocked(getProjectFullConfig).mockReturnValueOnce(pendingProjectTwo.promise)
await renderView()
await flushEffects()
expect(container.textContent).toContain('Variable proyecto 1')
await act(async () => {
useAuthStore.setState({
activeProjectId: 'project-2',
projects: [{ id: 'project-2', name: 'Bloque Sur' }],
})
})
await flushEffects()
expect(getProjectFullConfig).toHaveBeenLastCalledWith('project-2')
expect(container.textContent).toContain('Cargando variables reales')
expect(container.textContent).not.toContain('Variable proyecto 1')
expect(container.textContent).not.toContain('0 de 0 variables')
})
})

View File

@@ -0,0 +1,360 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, RotateCcw, Search, SlidersHorizontal, Table2 } from 'lucide-react'
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryResponse } from '../api/dashboard-api'
import { buildVariableExplorerRows, filterVariableExplorerRows, type VariableExplorerFilters, type VariableExplorerRow, type VariableStatus } from '../utils/variables-view-model'
import { buildTrendSeries, type TrendSeriesPoint } from '../utils/trends-view-model'
import { getAlarms, type AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
import { getProjectFullConfig, type EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import type { AssetData } from '../utils/telemetry-normalization'
const statusClassName: Record<VariableStatus, string> = {
disabled: 'border-slate-700 bg-slate-900 text-slate-400',
alarm: 'border-red-400/50 bg-red-500/15 text-red-100 shadow-[0_0_12px_rgba(248,113,113,0.12)]',
'out-of-range': 'border-orange-400/50 bg-orange-500/15 text-orange-100 shadow-[0_0_12px_rgba(251,146,60,0.12)]',
normal: 'border-emerald-400/45 bg-emerald-500/15 text-emerald-100 shadow-[0_0_12px_rgba(52,211,153,0.12)]',
'no-data': 'border-slate-700 bg-slate-950 text-slate-500',
}
const valueClassName: Record<VariableStatus, string> = {
disabled: 'text-slate-500',
alarm: 'text-red-100',
'out-of-range': 'text-orange-100',
normal: 'text-emerald-100',
'no-data': 'text-slate-500',
}
const trendColor: Record<VariableStatus, string> = {
disabled: '#64748b',
alarm: '#f87171',
'out-of-range': '#fb923c',
normal: '#34d399',
'no-data': '#64748b',
}
const defaultFilters: VariableExplorerFilters = {
search: '',
well: 'all',
category: 'all',
frequency: 'all',
status: 'all',
}
type VariablesSnapshot = {
projectId: string
config: EdgeProjectFullConfig
telemetry: AssetData[]
historyByAssetId: Record<string, TelemetryHistoryResponse | null>
alarms: AlarmEvent[]
}
type VariablesLoadState = 'idle' | 'initial-loading' | 'refreshing' | 'loaded' | 'empty' | 'error'
function uniqueOptions(rows: VariableExplorerRow[], key: keyof Pick<VariableExplorerRow, 'well' | 'category' | 'frequency'>) {
return Array.from(new Set(rows.map((row) => row[key]).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'es'))
}
export function VariablesView() {
const activeProjectId = useAuthStore((state) => state.activeProjectId)
const projects = useAuthStore((state) => state.projects)
const [snapshot, setSnapshot] = useState<VariablesSnapshot | null>(null)
const [loadState, setLoadState] = useState<VariablesLoadState>('idle')
const [error, setError] = useState<string | null>(null)
const [filters, setFilters] = useState<VariableExplorerFilters>(defaultFilters)
const latestRequestIdRef = useRef(0)
const snapshotRef = useRef<VariablesSnapshot | null>(null)
const resolvedProjectId = useMemo(() => {
if (activeProjectId) return activeProjectId
return projects.length === 1 ? projects[0]?.id ?? null : null
}, [activeProjectId, projects])
useEffect(() => {
let mounted = true
async function loadVariables(showLoading = true) {
if (!resolvedProjectId) {
snapshotRef.current = null
setSnapshot(null)
setLoadState('idle')
setError(null)
return
}
const requestProjectId = resolvedProjectId
const requestId = latestRequestIdRef.current + 1
latestRequestIdRef.current = requestId
const hasCurrentProjectSnapshot = snapshotRef.current?.projectId === requestProjectId
setLoadState((current) => {
if (showLoading && !hasCurrentProjectSnapshot) return 'initial-loading'
if (current === 'initial-loading' && !hasCurrentProjectSnapshot) return current
return 'refreshing'
})
setError(null)
try {
const [fullConfig, summary, activeAlarms] = await Promise.all([
getProjectFullConfig(requestProjectId),
fetchDashboardSummary(),
getAlarms(requestProjectId),
])
const wellAssetIds = fullConfig.assets
.filter(({ asset }) => asset.asset_type === 'POZO')
.map(({ asset }) => asset.id)
const historyEntries = await Promise.all(wellAssetIds.map(async (assetId) => {
try {
const history = await fetchAssetTelemetryHistory(assetId, 1, 48)
return [assetId, history] as const
} catch (historyError) {
console.warn('Variables trend history error:', historyError)
return [assetId, null] as const
}
}))
if (!mounted || latestRequestIdRef.current !== requestId) return
const nextSnapshot: VariablesSnapshot = {
projectId: requestProjectId,
config: fullConfig,
telemetry: summary.data || [],
historyByAssetId: Object.fromEntries(historyEntries),
alarms: activeAlarms,
}
const nextRows = buildVariableExplorerRows(nextSnapshot.config, nextSnapshot.telemetry, nextSnapshot.alarms)
snapshotRef.current = nextSnapshot
setSnapshot(nextSnapshot)
setLoadState(nextRows.length === 0 ? 'empty' : 'loaded')
} catch (loadError) {
if (!mounted || latestRequestIdRef.current !== requestId) return
void loadError
setError('No se pudo cargar la configuración real de variables.')
setLoadState('error')
}
}
loadVariables()
const refreshTimer = window.setInterval(() => { void loadVariables(false) }, 5000)
return () => {
mounted = false
window.clearInterval(refreshTimer)
}
}, [resolvedProjectId])
const currentSnapshot = snapshot?.projectId === resolvedProjectId ? snapshot : null
const rows = useMemo(() => buildVariableExplorerRows(currentSnapshot?.config ?? null, currentSnapshot?.telemetry ?? [], currentSnapshot?.alarms ?? []), [currentSnapshot])
const filteredRows = useMemo(() => filterVariableExplorerRows(rows, filters), [rows, filters])
const wellOptions = useMemo(() => uniqueOptions(rows, 'well'), [rows])
const categoryOptions = useMemo(() => uniqueOptions(rows, 'category'), [rows])
const frequencyOptions = useMemo(() => uniqueOptions(rows, 'frequency'), [rows])
const isInitialLoading = loadState === 'initial-loading' && rows.length === 0
const isBackgroundRefresh = loadState === 'refreshing' && rows.length > 0
const isErrorWithRows = loadState === 'error' && rows.length > 0
const resultLabel = isInitialLoading
? 'Cargando variables reales'
: !resolvedProjectId
? 'Sin proyecto activo'
: `${filteredRows.length} de ${rows.length} variables`
const setFilter = (key: keyof VariableExplorerFilters, value: string) => {
setFilters((current) => ({ ...current, [key]: value }))
}
const hasActiveFilters = Object.entries(filters).some(([key, value]) => value !== defaultFilters[key as keyof VariableExplorerFilters])
return (
<div className="relative min-h-screen overflow-hidden rounded-[1.75rem] border border-[#242a31] bg-[#090b0f] p-3 text-slate-100 shadow-2xl shadow-black/40 md:p-5">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,122,26,0.06),transparent_32%),linear-gradient(180deg,rgba(20,24,29,0.72),rgba(9,11,15,0.96))]" />
<div className="relative mb-4 flex flex-col gap-3 border-b border-cyan-400/10 pb-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-cyan-300/20 bg-cyan-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-100">
<Table2 className="h-3.5 w-3.5" /> Catálogo en tiempo real
</div>
<h1 className="text-2xl font-black uppercase tracking-tight text-white md:text-3xl">Explorador de variables</h1>
<p className="mt-1 max-w-3xl text-xs text-slate-400 md:text-sm">
Variables configuradas en pozos reales, cruzadas con la última telemetría y alarmas activas del proyecto.
</p>
</div>
<div className="rounded-xl border border-[#2a3038] bg-[#11151b]/90 px-4 py-2 text-right shadow-lg shadow-black/20">
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Resultado</p>
<p className="text-lg font-semibold tabular-nums text-white">{resultLabel}</p>
{isBackgroundRefresh ? <p className="text-[11px] text-cyan-200">Actualizando sin borrar datos previos</p> : null}
</div>
</div>
{isErrorWithRows ? (
<div className="relative mb-4 rounded-xl border border-amber-300/25 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
{error} Se conserva el último catálogo real cargado para evitar parpadeos.
</div>
) : null}
<section aria-label="Filtros avanzados" className="relative mb-4 rounded-[1.25rem] border border-[#252b33] bg-[#11151b]/85 p-3 shadow-xl shadow-black/20">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="inline-flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-cyan-100">
<SlidersHorizontal className="h-3.5 w-3.5 text-cyan-300" /> Filtros avanzados
</div>
<button
type="button"
onClick={() => setFilters(defaultFilters)}
disabled={!hasActiveFilters}
className="inline-flex items-center gap-2 rounded-full border border-orange-300/20 bg-orange-400/10 px-3 py-1 text-[11px] font-semibold text-orange-100 transition hover:border-orange-300/40 disabled:cursor-not-allowed disabled:border-slate-800 disabled:bg-slate-900/60 disabled:text-slate-600"
>
<RotateCcw className="h-3 w-3" /> Limpiar
</button>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-[1.2fr_1fr_1fr_0.9fr_0.9fr]">
<label className="relative flex flex-col gap-1 text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500">
Buscar
<Search className="pointer-events-none absolute bottom-2.5 left-3 h-4 w-4 text-slate-500" />
<input
value={filters.search}
onChange={(event) => setFilter('search', event.target.value)}
className="h-9 rounded-lg border border-[#2a3038] bg-[#0b0f14] py-2 pl-9 pr-3 text-sm normal-case tracking-normal text-white outline-none transition placeholder:text-slate-600 focus:border-cyan-300"
placeholder="Tag o variable"
/>
</label>
<FilterSelect label="Pozo" value={filters.well} onChange={(value) => setFilter('well', value)} options={wellOptions} />
<FilterSelect label="Categoría" value={filters.category} onChange={(value) => setFilter('category', value)} options={categoryOptions} />
<FilterSelect label="Frecuencia" value={filters.frequency} onChange={(value) => setFilter('frequency', value)} options={frequencyOptions} />
<FilterSelect
label="Estado"
value={filters.status}
onChange={(value) => setFilter('status', value)}
options={['normal', 'alarm', 'out-of-range', 'disabled', 'no-data']}
labels={{ normal: 'Normal', alarm: 'Alarma activa', 'out-of-range': 'Fuera de rango', disabled: 'Deshabilitada', 'no-data': 'Sin dato' }}
/>
</div>
</section>
{isInitialLoading ? (
<VariablesMessage title="Cargando variables reales" description="Consultando configuración, telemetría y alarmas del proyecto." />
) : loadState === 'error' && rows.length === 0 ? (
<VariablesMessage icon={<AlertTriangle className="h-5 w-5 text-amber-300" />} title="No se pudo cargar la vista" description={error} />
) : !resolvedProjectId ? (
<VariablesMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver sus variables configuradas." />
) : loadState === 'empty' && rows.length === 0 ? (
<VariablesMessage title="Sin variables reales" description="No hay variables configuradas en assets de tipo POZO para este proyecto." />
) : filteredRows.length === 0 ? (
<VariablesMessage title="Sin coincidencias" description="Los filtros actuales no coinciden con variables reales del proyecto." />
) : (
<div className="relative overflow-hidden rounded-[1.25rem] border border-[#252b33] bg-[#0b0f14]/95 shadow-2xl shadow-black/30">
<div className="overflow-x-auto">
<table className="min-w-full border-separate border-spacing-0 text-xs">
<thead className="bg-[#11151b] text-[10px] uppercase tracking-[0.16em] text-slate-500">
<tr>
<TableHead>TAG</TableHead>
<TableHead>VARIABLE</TableHead>
<TableHead>POZO</TableHead>
<TableHead>FREC.</TableHead>
<TableHead>VALOR</TableHead>
<TableHead>TENDENCIA</TableHead>
<TableHead>CALIDAD</TableHead>
<TableHead>ESTADO</TableHead>
<TableHead>ACTUALIZADO</TableHead>
</tr>
</thead>
<tbody>
{filteredRows.map((row) => {
const trendPoints = buildTrendSeries(currentSnapshot?.historyByAssetId[row.assetId] ?? null, row.tag).slice(-16)
return (
<tr key={row.id} className="border-b border-[#252b33]/80 transition hover:bg-white/[0.025]">
<TableCell><span className="font-mono text-[11px] font-semibold text-cyan-200">{row.tag}</span></TableCell>
<TableCell><div className="font-semibold text-white">{row.variable}</div><div className="text-[11px] text-slate-500">{row.deviceName}</div></TableCell>
<TableCell>{row.well}</TableCell>
<TableCell><span className="rounded border border-slate-800 bg-slate-950 px-2 py-1 font-mono text-[11px] text-slate-300">{row.frequency}</span></TableCell>
<TableCell><span className={`font-mono text-[13px] font-bold tabular-nums ${valueClassName[row.status]}`}>{row.value}</span></TableCell>
<TableCell><MiniTrend points={trendPoints} color={trendColor[row.status]} /></TableCell>
<TableCell><QualityBadge quality={row.quality} /></TableCell>
<TableCell><span className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-semibold ${statusClassName[row.status]}`}>{row.statusLabel}</span></TableCell>
<TableCell><span className="font-mono text-[11px] text-slate-400">{row.updatedAtLabel}</span></TableCell>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
function FilterSelect({ label, value, onChange, options, labels = {} }: { label: string; value: string; onChange: (value: string) => void; options: string[]; labels?: Record<string, string> }) {
return (
<label className="flex flex-col gap-1 text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500">
{label}
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-9 rounded-lg border border-[#2a3038] bg-[#0b0f14] px-3 py-2 text-sm normal-case tracking-normal text-white outline-none transition focus:border-cyan-300"
>
<option value="all">Todos</option>
{options.map((option) => <option key={option} value={option}>{labels[option] ?? option}</option>)}
</select>
</label>
)
}
function TableHead({ children }: { children: ReactNode }) {
return <th className="whitespace-nowrap border-b border-slate-800 px-3 py-2 text-left font-semibold">{children}</th>
}
function TableCell({ children }: { children: ReactNode }) {
return <td className="whitespace-nowrap border-b border-slate-800/70 px-3 py-2 text-slate-300">{children}</td>
}
function MiniTrend({ points, color }: { points: TrendSeriesPoint[]; color: string }) {
if (points.length < 2) {
return <span className="text-[11px] text-slate-600">Sin datos</span>
}
const width = 96
const height = 28
const values = points.map((point) => point.value)
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const polyline = points.map((point, index) => {
const x = points.length === 1 ? width : (index / (points.length - 1)) * width
const y = height - ((point.value - min) / range) * (height - 4) - 2
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
const delta = points[points.length - 1].value - points[0].value
const deltaLabel = delta > 0 ? 'al alza' : delta < 0 ? 'a la baja' : 'estable'
return (
<div className="flex items-center">
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Tendencia real: ${deltaLabel}`} className="overflow-visible">
<polyline points={polyline} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)
}
function QualityBadge({ quality }: { quality: string }) {
const normalized = quality.toLowerCase()
const className = normalized === 'good' || normalized === 'ok'
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100'
: quality === 'Sin dato'
? 'border-slate-800 bg-slate-950 text-slate-500'
: 'border-orange-400/30 bg-orange-500/10 text-orange-100'
return <span className={`inline-flex rounded border px-2 py-0.5 text-[11px] font-semibold ${className}`}>{quality}</span>
}
function VariablesMessage({ title, description, icon }: { title: string; description: string; icon?: ReactNode }) {
return (
<div className="flex min-h-64 items-center justify-center rounded-[1.5rem] border border-dashed border-slate-800 bg-slate-950/50 p-8 text-center">
<div className="max-w-md">
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-full border border-slate-800 bg-slate-900 text-slate-400">
{icon ?? <Table2 className="h-5 w-5" />}
</div>
<h2 className="text-base font-semibold text-white">{title}</h2>
<p className="mt-2 text-sm text-slate-400">{description}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,378 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WellDashboardPage from './WellDashboardPage'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getProjectFullConfig } from '../../projects/api/projects-api'
import { fetchAssetTelemetry, fetchAssetTelemetryHistory, fetchDashboardSummary } from '../api/dashboard-api'
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
vi.mock('../../projects/api/projects-api', () => ({
getProjectFullConfig: vi.fn(),
}))
vi.mock('../api/dashboard-api', () => ({
fetchDashboardSummary: vi.fn(),
fetchAssetTelemetry: vi.fn(),
fetchAssetTelemetryHistory: vi.fn(),
}))
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
getAlarms: vi.fn(),
}))
async function flushQueries() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
async function waitForText(container: HTMLElement, text: string) {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (container.textContent?.includes(text)) return
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('WellDashboardPage', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
let queryClient: QueryClient
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
useAuthStore.setState({
token: 'token-1',
accessToken: 'token-1',
activeProjectId: 'project-1',
projects: [{ id: 'project-1', name: 'Bloque Norte' }],
})
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [
{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
devices: [
{
device: { id: 'device-1', asset_id: 'well-1', name: 'PLC Pozo', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
variables: [
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'presion', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: true, alarm_min: 80, alarm_max: 150, byte_order: 'ABCD', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-2', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'temperatura', unit: '°C', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Temperatura motor' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-3', device_id: 'device-1', address: '40003', data_type: 'float32', alias: 'nivel_tanque', unit: '%', polling_interval_ms: 60000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Nivel tanque' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
],
},
],
},
],
})
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { values: { presion: 120, nivel_tanque: 72 } } }],
})
vi.mocked(fetchAssetTelemetry).mockResolvedValue([])
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
{ asset_id: 'well-1', time: '2026-06-22T09:55:00Z', data: { values: { presion: 118, nivel_tanque: 71 } } },
{ asset_id: 'well-1', time: '2026-06-22T10:00:00Z', data: { values: { presion: 120, nivel_tanque: 72 } } },
] })
vi.mocked(getAlarms).mockResolvedValue([
{ id: 'alarm-1', project_id: 'project-1', asset_id: 'well-1', variable_alias: 'presion', alarm_type: 'ABOVE_MAX', message: 'Presión alta', acknowledged: false, timestamp: '2026-06-22T10:01:00Z' },
])
})
afterEach(async () => {
await act(async () => {
root.unmount()
})
queryClient.clear()
document.body.innerHTML = ''
window.localStorage.clear()
vi.clearAllMocks()
useAuthStore.setState({ token: null, accessToken: null, activeProjectId: null, projects: [] })
})
it('renders selected well data from project config, telemetry summary and alarms', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, 'Pozo Norte')
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
expect(fetchAssetTelemetry).not.toHaveBeenCalled()
expect(getAlarms).toHaveBeenCalledWith('project-1')
expect(container.textContent).toContain('Pozo Norte')
expect(container.textContent).toContain('Activo')
expect(container.textContent).toContain('Con telemetría')
expect(container.textContent).toContain('120')
expect(container.textContent).toContain('Presión alta')
})
it('renders selected variable chart and latest well alerts below gauges', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, 'Presión alta')
const gaugeSection = container.querySelector('section[aria-label="Gauges de variables críticas"]')
const chartAlertsSection = container.querySelector('section[aria-label="Tendencia seleccionada y últimas alertas"]')
expect(gaugeSection).not.toBeNull()
expect(chartAlertsSection).not.toBeNull()
expect(gaugeSection?.compareDocumentPosition(chartAlertsSection as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(chartAlertsSection?.textContent).toContain('Variable seleccionada')
expect(chartAlertsSection?.textContent).toContain('Último valor')
expect(chartAlertsSection?.textContent).toContain('Últimas alertas del pozo')
expect(chartAlertsSection?.textContent).toContain('Presión alta')
expect(chartAlertsSection?.textContent).toContain('Alerta')
})
it('renders gauge cards with real selected variables and configured ranges', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, 'presion')
expect(container.textContent).toContain('Variables principales')
expect(container.textContent).toContain('Telemetría crítica')
expect(container.textContent).toContain('presion')
expect(container.textContent).toContain('120')
expect(container.textContent).toContain('PSI')
expect(container.textContent).toContain('rango 80-150 PSI')
expect(container.textContent).not.toContain('185')
})
it('persists edited gauge ranges in localStorage', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, 'presion')
const minInput = container.querySelector('input[aria-label="Mínimo presion"]') as HTMLInputElement
const maxInput = container.querySelector('input[aria-label="Máximo presion"]') as HTMLInputElement
const applyButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Aplicar') as HTMLButtonElement
await act(async () => {
setInputValue(minInput, '90')
setInputValue(maxInput, '140')
applyButton.click()
})
expect(container.textContent).toContain('rango 90-140 PSI')
expect(window.localStorage.getItem('well-dashboard:gauge-range:well-1:var-1')).toBe('{"min":90,"max":140}')
})
it('validates editable gauge ranges before applying them', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, 'presion')
const minInput = container.querySelector('input[aria-label="Mínimo presion"]') as HTMLInputElement
const maxInput = container.querySelector('input[aria-label="Máximo presion"]') as HTMLInputElement
await act(async () => {
setInputValue(minInput, '150')
setInputValue(maxInput, '120')
})
const applyButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Aplicar') as HTMLButtonElement
expect(container.textContent).toContain('El mínimo debe ser menor que el máximo.')
expect(applyButton.disabled).toBe(true)
})
it('renders only active high-frequency variables and omits no-data cards', async () => {
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
assets: [
{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
},
devices: [
{
device: { id: 'device-1', asset_id: 'well-1', name: 'PLC Pozo', protocol: 'MODBUS_TCP', host: '127.0.0.1', port: 502, protocol_config: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
variables: [
{ id: 'var-1', device_id: 'device-1', address: '40001', data_type: 'float32', alias: 'presion', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: true, alarm_min: 80, alarm_max: 150, byte_order: 'ABCD', metadata: {}, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-2', device_id: 'device-1', address: '40002', data_type: 'float32', alias: 'temperatura', unit: '°C', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Temperatura motor' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-3', device_id: 'device-1', address: '40003', data_type: 'float32', alias: 'nivel_tanque', unit: '%', polling_interval_ms: 60000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Nivel tanque' }, is_enabled: true, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
{ id: 'var-disabled', device_id: 'device-1', address: '40004', data_type: 'float32', alias: 'deshabilitada', unit: 'PSI', polling_interval_ms: 5000, alarm_enabled: false, byte_order: 'ABCD', metadata: { label: 'Variable deshabilitada' }, is_enabled: false, created_at: '2026-06-22T00:00:00Z', updated_at: '2026-06-22T00:00:00Z' },
],
},
],
},
],
})
vi.mocked(fetchDashboardSummary).mockResolvedValue({
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { values: { presion: 120, nivel_tanque: 72, deshabilitada: 999 } } }],
})
vi.mocked(getAlarms).mockResolvedValue([
{ id: 'alarm-1', project_id: 'project-1', asset_id: 'well-1', variable_alias: 'temperatura', alarm_type: 'ABOVE_MAX', message: 'Temperatura alta', acknowledged: false, timestamp: '2026-06-22T10:01:00Z' },
])
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, '1 señales reales')
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
expect(container.textContent).toContain('Variables de alta frecuencia')
expect(highFrequencySection?.textContent).toContain('1 señales reales')
expect(highFrequencySection?.textContent).not.toContain('120 señales')
expect(highFrequencySection?.textContent).not.toContain('100 señales')
expect(highFrequencySection?.textContent).toContain('presion')
expect(highFrequencySection?.textContent).toContain('120')
expect(highFrequencySection?.textContent).not.toContain('Temperatura motor')
expect(highFrequencySection?.textContent).not.toContain('Temperatura alta')
expect(highFrequencySection?.textContent).not.toContain('Variable deshabilitada')
expect(highFrequencySection?.textContent).not.toContain('999')
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
})
it('renders low-frequency variables separately from high-frequency variables', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, '1 señales reales')
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
expect(highFrequencySection?.textContent).toContain('1 señales reales')
expect(highFrequencySection?.textContent).not.toContain('Nivel tanque')
expect(lowFrequencySection?.textContent).toContain('Nivel tanque')
expect(lowFrequencySection?.textContent).toContain('72')
expect(lowFrequencySection?.textContent).toContain('Esperada: Cada 1 min')
})
it('shows honest empty states when the well has no telemetry or alarms', async () => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({ data: [] })
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [] })
vi.mocked(getAlarms).mockResolvedValue([])
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
<Routes>
<Route path="/app/admin/wells/:wellId" element={<WellDashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushQueries()
await waitForText(container, 'Sin alertas activas')
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
expect(container.textContent).toContain('Sin dato')
expect(container.textContent).toContain('Sin alertas activas')
expect(container.textContent).toContain('Sin historial')
expect(highFrequencySection?.textContent).toContain('0 señales reales')
expect(highFrequencySection?.textContent).toContain('Sin variables configuradas')
expect(highFrequencySection?.textContent).not.toContain('presion')
expect(highFrequencySection?.textContent).not.toContain('Temperatura motor')
expect(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
expect(fetchAssetTelemetry).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,744 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Activity, ArrowLeft, Database, Gauge } from 'lucide-react'
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Badge } from '@/components/ui/badge'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryRecord } from '../api/dashboard-api'
import { DashboardEmptyState } from '../components/DashboardEmptyState'
import { TelemetryChart } from '../components/TelemetryChart'
import type { AssetData } from '../utils/telemetry-normalization'
import { normalizeTelemetry } from '../utils/telemetry-normalization'
import {
getUnitBackgroundClass,
getUnitColorClass,
groupChartData,
} from '../utils/dashboard-view-model'
import { getProjectFullConfig } from '../../projects/api/projects-api'
import type { EdgeVariable } from '../../projects/api/variables-api'
type VariableSignalStatus = 'alarm' | 'warning' | 'normal' | 'no-data'
type GaugeRange = { min: number; max: number; source: 'metadata' | 'history' | 'value' }
type FrequencyClass = 'high' | 'low' | 'unknown'
const GAUGE_RANGE_STORAGE_PREFIX = 'well-dashboard:gauge-range'
const gaugeArcPath = 'M 20 88 A 68 68 0 0 1 156 88'
function normalizeHistoryRecord(record: TelemetryHistoryRecord): { time: string; asset_id?: string; data?: Record<string, unknown> } {
const data = record.data || record.values || record.telemetry || record
const time = record.time || record.created_at || record.timestamp || new Date().toISOString()
return {
time,
asset_id: record.asset_id,
data,
}
}
function formatDateTime(value?: string) {
if (!value) return 'Sin dato'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Sin dato'
return date.toLocaleString('es-AR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function getVariableLabel(variable: EdgeVariable) {
const metadataLabel = variable.metadata?.label ?? variable.metadata?.name ?? variable.metadata?.nombre
return typeof metadataLabel === 'string' && metadataLabel.trim() ? metadataLabel : variable.alias
}
function getNumericMetadata(metadata: Record<string, unknown> | undefined, keys: string[]) {
if (!metadata) return null
for (const key of keys) {
const value = metadata[key]
const numberValue = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
if (Number.isFinite(numberValue)) return numberValue
}
return null
}
function getVariableValue(variable: EdgeVariable, telemetry?: Record<string, unknown> | null): number | string | null {
if (!telemetry) return null
const normalized = normalizeTelemetry(telemetry)
const normalizedValue = normalized[variable.alias]?.value
const directValue = telemetry[variable.alias]
const nestedValues = telemetry.values && typeof telemetry.values === 'object' ? telemetry.values as Record<string, unknown> : null
const nestedValue = nestedValues?.[variable.alias]
if (typeof normalizedValue === 'number' && Number.isFinite(normalizedValue)) return normalizedValue
if (typeof directValue === 'number' || typeof directValue === 'string') return directValue
if (typeof nestedValue === 'number' || typeof nestedValue === 'string') return nestedValue
return null
}
function formatVariableValue(value: number | string | null) {
if (value === null || value === '') return 'Sin dato'
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(1)
return value
}
function formatRangeValue(value: number) {
return Number.isInteger(value) ? String(value) : Number(value.toFixed(1)).toString()
}
function getVariableSparkline(variable: EdgeVariable, history: Array<{ data?: Record<string, unknown> }>) {
return history
.map((record) => getVariableValue(variable, record.data))
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
.reverse()
}
function getPollingIntervalMs(variable: EdgeVariable) {
const direct = typeof variable.polling_interval_ms === 'number' ? variable.polling_interval_ms : null
return direct ?? getNumericMetadata(variable.metadata, ['polling_interval_ms', 'pollingIntervalMs', 'polling_interval', 'pollingInterval'])
}
function getFrequencyClass(variable: EdgeVariable): FrequencyClass {
const metadata = variable.metadata ?? {}
const category = String(metadata.frequency_category ?? metadata.frequencyCategory ?? metadata.frequency ?? metadata.frecuencia ?? '').toLowerCase()
if (['high', 'alta', 'alta frecuencia', 'high-frequency'].includes(category)) return 'high'
if (['low', 'baja', 'baja frecuencia', 'low-frequency'].includes(category)) return 'low'
const pollingIntervalMs = getPollingIntervalMs(variable)
if (typeof pollingIntervalMs === 'number') return pollingIntervalMs >= 60000 ? 'low' : 'high'
return 'unknown'
}
function formatFrequency(variable: EdgeVariable) {
const pollingIntervalMs = getPollingIntervalMs(variable)
if (typeof pollingIntervalMs !== 'number') return 'Frecuencia no configurada'
if (pollingIntervalMs >= 60000) return `Cada ${Math.round(pollingIntervalMs / 60000)} min`
if (pollingIntervalMs >= 1000) return `Cada ${Math.round(pollingIntervalMs / 1000)} s`
return `Cada ${pollingIntervalMs} ms`
}
function getVariableCategory(variable: EdgeVariable) {
const category = variable.metadata?.category ?? variable.metadata?.categoria ?? variable.data_type
return typeof category === 'string' && category.trim() ? category : 'Variable'
}
function getAlarmSeverityLabel(alarm: { severity?: unknown; level?: unknown; priority?: unknown }) {
const rawSeverity = alarm.severity ?? alarm.level ?? alarm.priority
if (typeof rawSeverity !== 'string' && typeof rawSeverity !== 'number') return 'Alerta'
const severity = String(rawSeverity).toLowerCase()
if (['alta', 'high', 'critical', 'critica', 'crítica', '3'].includes(severity)) return 'ALTA'
if (['media', 'medium', 'warning', '2'].includes(severity)) return 'MEDIA'
if (['baja', 'low', 'info', '1'].includes(severity)) return 'BAJA'
return String(rawSeverity).toUpperCase()
}
function getGaugeRangeStorageKey(wellId: string | undefined, variable: EdgeVariable) {
return `${GAUGE_RANGE_STORAGE_PREFIX}:${wellId ?? 'sin-pozo'}:${variable.id || variable.alias}`
}
function readStoredGaugeRange(key: string): { min: number; max: number } | null {
if (typeof window === 'undefined') return null
try {
const parsed = JSON.parse(window.localStorage.getItem(key) ?? 'null') as { min?: unknown; max?: unknown } | null
if (!parsed) return null
const min = typeof parsed.min === 'number' ? parsed.min : Number(parsed.min)
const max = typeof parsed.max === 'number' ? parsed.max : Number(parsed.max)
return Number.isFinite(min) && Number.isFinite(max) && min < max ? { min, max } : null
} catch {
return null
}
}
function writeStoredGaugeRange(key: string, range: { min: number; max: number }) {
if (typeof window === 'undefined') return
window.localStorage.setItem(key, JSON.stringify(range))
}
function getDefaultGaugeRange(variable: EdgeVariable, value: number | string | null, sparklineValues: number[]): GaugeRange {
const metadataMin = getNumericMetadata(variable.metadata, ['range_min', 'rangeMin', 'threshold_min', 'thresholdMin', 'min'])
const metadataMax = getNumericMetadata(variable.metadata, ['range_max', 'rangeMax', 'threshold_max', 'thresholdMax', 'max'])
const configuredMin = typeof variable.alarm_min === 'number' ? variable.alarm_min : metadataMin
const configuredMax = typeof variable.alarm_max === 'number' ? variable.alarm_max : metadataMax
if (typeof configuredMin === 'number' && typeof configuredMax === 'number' && configuredMin < configuredMax) {
return { min: configuredMin, max: configuredMax, source: 'metadata' }
}
if (sparklineValues.length > 0) {
const historyMin = Math.min(...sparklineValues)
const historyMax = Math.max(...sparklineValues)
if (historyMin < historyMax) return { min: historyMin, max: historyMax, source: 'history' }
return { min: historyMin - 1, max: historyMax + 1, source: 'history' }
}
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
const padding = Math.max(Math.abs(numericValue) * 0.2, 1)
return { min: numericValue - padding, max: numericValue + padding, source: 'value' }
}
function getGaugePriority(variable: EdgeVariable, label: string) {
const searchable = `${variable.alias} ${label} ${String(variable.metadata?.category ?? '')} ${String(variable.metadata?.categoria ?? '')}`.toLowerCase()
if (searchable.includes('pres') || searchable.includes('pressure')) return 0
if (searchable.includes('temp')) return 1
if (searchable.includes('caudal') || searchable.includes('flow')) return 2
if (searchable.includes('vib')) return 3
return 10
}
function getGaugeStatus(value: number | string | null, range: { min: number; max: number }): VariableSignalStatus {
if (typeof value !== 'number' || !Number.isFinite(value)) return 'no-data'
if (value < range.min || value > range.max) return 'warning'
return 'normal'
}
function getGaugeFill(value: number | string | null, range: { min: number; max: number }) {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
const percent = ((value - range.min) / (range.max - range.min)) * 100
return Math.min(100, Math.max(0, percent))
}
function buildSparklinePath(values: number[]) {
if (values.length < 2) return ''
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const points = values.map((value, index) => {
const x = (index / (values.length - 1)) * 100
const y = 28 - ((value - min) / range) * 24
return `${x.toFixed(2)},${y.toFixed(2)}`
})
return `M ${points.join(' L ')}`
}
function getVariableStatus(variable: EdgeVariable, value: number | string | null, hasAlarm: boolean): VariableSignalStatus {
if (hasAlarm) return 'alarm'
if (typeof value !== 'number') return 'no-data'
if (typeof variable.alarm_min === 'number' && value < variable.alarm_min) return 'warning'
if (typeof variable.alarm_max === 'number' && value > variable.alarm_max) return 'warning'
return 'normal'
}
const variableStatusClasses: Record<VariableSignalStatus, string> = {
alarm: 'bg-[#ef4444] shadow-[0_0_12px_rgba(239,68,68,0.8)]',
warning: 'bg-[#f59e0b] shadow-[0_0_12px_rgba(245,158,11,0.7)]',
normal: 'bg-[#27d796] shadow-[0_0_12px_rgba(39,215,150,0.7)]',
'no-data': 'bg-[#64748b]',
}
export default function WellDashboardPage() {
const { wellId } = useParams()
const [visibleVars, setVisibleVars] = useState<Record<string, boolean>>({})
const [storedGaugeRanges, setStoredGaugeRanges] = useState<Record<string, { min: number; max: number }>>({})
const [draftGaugeRanges, setDraftGaugeRanges] = useState<Record<string, { min: string; max: string }>>({})
const [selectedVariableId, setSelectedVariableId] = useState<string | null>(null)
const activeProjectId = useAuthStore((s) => s.activeProjectId)
const projects = useAuthStore((s) => s.projects)
const token = useAuthStore((s) => s.token)
const resolvedProjectId = activeProjectId ?? (projects.length === 1 ? projects[0]?.id : null)
const projectConfigQuery = useQuery({
queryKey: ['well-dashboard-project-config', resolvedProjectId],
queryFn: () => getProjectFullConfig(resolvedProjectId!),
enabled: Boolean(token && resolvedProjectId),
staleTime: 60000,
})
const summaryQuery = useQuery({
queryKey: ['well-dashboard-summary'],
queryFn: fetchDashboardSummary,
enabled: Boolean(token),
refetchInterval: 5000,
})
const alarmsQuery = useQuery({
queryKey: ['well-dashboard-alarms', resolvedProjectId],
queryFn: () => getAlarms(resolvedProjectId),
enabled: Boolean(token && resolvedProjectId),
refetchInterval: 60000,
})
const historyQuery = useQuery({
queryKey: ['well-dashboard-history', wellId],
queryFn: () => fetchAssetTelemetryHistory(wellId!, 1, 100),
enabled: Boolean(token && wellId),
})
const wellConfig = useMemo(() => {
return projectConfigQuery.data?.assets.find(({ asset }) => asset.id === wellId && asset.asset_type === 'POZO') ?? null
}, [projectConfigQuery.data, wellId])
const well = wellConfig?.asset ?? null
const wellName = well?.name || well?.code || wellId?.slice(0, 8) || 'Pozo'
const variables = useMemo(() => wellConfig?.devices.flatMap((device) => device.variables) ?? [], [wellConfig])
const enabledVariables = useMemo(() => variables.filter((variable) => variable.is_enabled), [variables])
const activeAlarms = (alarmsQuery.data ?? []).filter((alarm) => alarm.asset_id === wellId && !alarm.acknowledged)
const history = useMemo(() => {
const raw = historyQuery.data as TelemetryHistoryRecord[] | { records?: TelemetryHistoryRecord[] } | undefined
const records = Array.isArray(raw) ? raw : Array.isArray(raw?.records) ? raw.records : []
return records.map(normalizeHistoryRecord)
}, [historyQuery.data])
const latestAssetData = useMemo<AssetData | null>(() => {
const asset = summaryQuery.data?.data.find((item) => item.asset_id === wellId)
if (asset) return asset
const latestHistory = history[0]
if (!latestHistory?.data || !wellId) return null
return {
asset_id: wellId,
asset: wellName,
project_id: resolvedProjectId ?? undefined,
last_updated: latestHistory.time,
telemetry: latestHistory.data,
}
}, [history, resolvedProjectId, summaryQuery.data, wellId, wellName])
const chartGroupedData = useMemo(() => groupChartData(history, wellId ?? 'all'), [history, wellId])
const variableSignals = useMemo(() => {
return enabledVariables.map((variable) => {
const value = getVariableValue(variable, latestAssetData?.telemetry)
const sparklineValues = getVariableSparkline(variable, history)
const hasAlarm = activeAlarms.some((alarm) => alarm.variable_alias === variable.alias)
const status = getVariableStatus(variable, value, hasAlarm)
return {
variable,
label: getVariableLabel(variable),
value,
sparklineValues,
sparklinePath: buildSparklinePath(sparklineValues),
status,
frequencyClass: getFrequencyClass(variable),
frequencyLabel: formatFrequency(variable),
}
})
}, [activeAlarms, enabledVariables, history, latestAssetData?.telemetry])
const highFrequencySignals = useMemo(() => (
variableSignals.filter((signal) => signal.frequencyClass !== 'low' && typeof signal.value === 'number' && Number.isFinite(signal.value))
), [variableSignals])
const lowFrequencySignals = useMemo(() => variableSignals.filter((signal) => signal.frequencyClass === 'low'), [variableSignals])
const gaugeSignals = useMemo(() => {
return variableSignals
.map((signal, index) => ({ ...signal, index, priority: getGaugePriority(signal.variable, signal.label) }))
.sort((a, b) => a.priority - b.priority || a.index - b.index)
.slice(0, 4)
.map((signal) => {
const storageKey = getGaugeRangeStorageKey(wellId, signal.variable)
const defaultRange = getDefaultGaugeRange(signal.variable, signal.value, signal.sparklineValues)
const appliedRange = storedGaugeRanges[storageKey] ?? defaultRange
const status = getGaugeStatus(signal.value, appliedRange)
const fill = getGaugeFill(signal.value, appliedRange)
return {
...signal,
storageKey,
appliedRange,
defaultRange,
status,
fill,
}
})
}, [storedGaugeRanges, variableSignals, wellId])
const selectableSignals = gaugeSignals.length > 0 ? gaugeSignals : variableSignals.slice(0, 6)
const selectedSignal = selectableSignals.find((signal) => signal.variable.id === selectedVariableId) ?? selectableSignals[0] ?? null
const selectedChartData = useMemo(() => {
if (!selectedSignal) return []
return [...history]
.reverse()
.map((record) => ({
time: formatDateTime(record.time),
value: getVariableValue(selectedSignal.variable, record.data),
}))
.filter((point): point is { time: string; value: number } => typeof point.value === 'number' && Number.isFinite(point.value))
}, [history, selectedSignal])
const loading = projectConfigQuery.isLoading || summaryQuery.isLoading || historyQuery.isLoading
const hasProject = Boolean(resolvedProjectId)
useEffect(() => {
const nextRanges: Record<string, { min: number; max: number }> = {}
for (const variable of enabledVariables) {
const storageKey = getGaugeRangeStorageKey(wellId, variable)
const storedRange = readStoredGaugeRange(storageKey)
if (storedRange) nextRanges[storageKey] = storedRange
}
setStoredGaugeRanges(nextRanges)
}, [enabledVariables, wellId])
const toggleVariable = (varName: string) => {
setVisibleVars((prev) => ({ ...prev, [varName]: prev[varName] === false }))
}
const updateGaugeDraft = (storageKey: string, field: 'min' | 'max', value: string, fallback: { min: number; max: number }) => {
setDraftGaugeRanges((prev) => ({
...prev,
[storageKey]: {
min: prev[storageKey]?.min ?? formatRangeValue(fallback.min),
max: prev[storageKey]?.max ?? formatRangeValue(fallback.max),
[field]: value,
},
}))
}
const applyGaugeRange = (storageKey: string, range: { min: number; max: number }) => {
writeStoredGaugeRange(storageKey, range)
setStoredGaugeRanges((prev) => ({ ...prev, [storageKey]: range }))
setDraftGaugeRanges((prev) => {
const { [storageKey]: _removed, ...rest } = prev
return rest
})
}
if (!hasProject) {
return (
<DashboardEmptyState
icon={Gauge}
label="Pozos"
title="Seleccioná un proyecto"
description="El dashboard del pozo necesita un proyecto activo para cargar configuración, alarmas y telemetría real."
/>
)
}
if (!loading && !well) {
return (
<DashboardEmptyState
icon={Gauge}
label="Pozos"
title="Pozo no encontrado"
description="No hay un pozo configurado con ese identificador dentro del proyecto activo."
/>
)
}
return (
<div className="flex flex-col gap-6 pb-8">
<div className="flex flex-col gap-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-5 shadow-2xl shadow-black/20 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 space-y-3">
<Link to="/app/admin/dashboard" className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#9aa8ba] transition-colors hover:text-primary">
<ArrowLeft className="h-3.5 w-3.5" /> Panorama
</Link>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-primary">Dashboard por pozo</p>
<h1 className="mt-1 truncate text-3xl font-bold tracking-tight text-foreground">{wellName}</h1>
<p className="mt-1 text-sm text-muted-foreground">{well?.code ? `Código ${well.code}` : 'Sin código configurado'}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge className={well?.is_active ? 'border-[#27d796]/30 bg-[#27d796]/10 text-[#27d796]' : 'border-muted bg-muted text-muted-foreground'}>
{well?.is_active ? 'Activo' : 'Inactivo'}
</Badge>
<Badge className={latestAssetData ? 'border-[#00b7ff]/30 bg-[#00b7ff]/10 text-[#00b7ff]' : 'border-[#2a3038] bg-[#090b0f] text-muted-foreground'}>
{latestAssetData ? 'Con telemetría' : 'Sin dato'}
</Badge>
{activeAlarms.length > 0 && (
<Badge className="border-destructive/30 bg-destructive/10 text-destructive">
{activeAlarms.length} alertas activas
</Badge>
)}
</div>
</div>
<section aria-label="Gauges de variables críticas" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#080b0f] p-4 shadow-2xl shadow-black/30">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#7d8da1]">Telemetría crítica</p>
<h2 className="text-xl font-semibold tracking-tight text-[#f6f8fb]">Variables principales</h2>
</div>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#f59e0b]">
Última señal {formatDateTime(latestAssetData?.last_updated)}
</span>
</div>
{gaugeSignals.length === 0 ? (
<DashboardEmptyState
icon={Gauge}
label="Variables"
title="Sin variables configuradas"
description="Este pozo no tiene variables habilitadas para construir gauges reales."
/>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{gaugeSignals.map(({ variable, label, value, storageKey, appliedRange, defaultRange, status, fill }) => {
const formattedValue = formatVariableValue(value)
const draftRange = draftGaugeRanges[storageKey]
const minInput = draftRange?.min ?? formatRangeValue(appliedRange.min)
const maxInput = draftRange?.max ?? formatRangeValue(appliedRange.max)
const parsedMin = Number(minInput)
const parsedMax = Number(maxInput)
const isRangeValid = Number.isFinite(parsedMin) && Number.isFinite(parsedMax) && parsedMin < parsedMax
const rangeSourceLabel = defaultRange.source === 'metadata' ? 'Rango configurado' : defaultRange.source === 'history' ? 'Rango inferido del historial' : 'Rango editable estimado'
return (
<article key={variable.id} aria-label={`${label}: ${formattedValue} ${variable.unit ?? ''}`} className="rounded-[1.5rem] border border-[#222b36] bg-[#11161d] p-4 shadow-inner shadow-white/[0.03]">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[#f6f8fb]">{label}</p>
<p className="mt-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#7d8da1]">{rangeSourceLabel}</p>
</div>
<span aria-label={`Estado ${status}`} className={`h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
</div>
<div className="relative mx-auto mt-2 h-[112px] max-w-[190px]">
<svg viewBox="0 0 176 100" role="img" aria-label={`Gauge de ${label}`} className="h-full w-full">
<path d={gaugeArcPath} fill="none" stroke="#27313d" strokeLinecap="round" strokeWidth="15" />
<path d={gaugeArcPath} fill="none" pathLength="100" stroke="#f97316" strokeDasharray={`${fill} 100`} strokeLinecap="round" strokeWidth="15" />
</svg>
<div className="absolute inset-x-0 bottom-2 text-center">
<div className="text-3xl font-semibold tabular-nums text-[#f6f8fb]">{formattedValue}</div>
{variable.unit && <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#8b98aa]">{variable.unit}</div>}
</div>
</div>
<p className="mt-2 text-center text-xs text-[#9aa8ba]">
rango {formatRangeValue(appliedRange.min)}-{formatRangeValue(appliedRange.max)}{variable.unit ? ` ${variable.unit}` : ''}
</p>
<div className="mt-3 grid grid-cols-[1fr_1fr_auto] gap-2">
<label className="space-y-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7d8da1]">
Min
<input
aria-label={`Mínimo ${label}`}
className="h-8 w-full rounded-lg border border-[#2a3038] bg-[#080b0f] px-2 text-xs font-medium text-[#f6f8fb] outline-none focus:border-[#f97316]"
inputMode="decimal"
value={minInput}
onChange={(event) => updateGaugeDraft(storageKey, 'min', event.target.value, appliedRange)}
/>
</label>
<label className="space-y-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7d8da1]">
Max
<input
aria-label={`Máximo ${label}`}
className="h-8 w-full rounded-lg border border-[#2a3038] bg-[#080b0f] px-2 text-xs font-medium text-[#f6f8fb] outline-none focus:border-[#f97316]"
inputMode="decimal"
value={maxInput}
onChange={(event) => updateGaugeDraft(storageKey, 'max', event.target.value, appliedRange)}
/>
</label>
<button
type="button"
className="mt-5 h-8 rounded-lg bg-[#f97316] px-3 text-xs font-semibold text-[#11161d] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
disabled={!isRangeValid}
onClick={() => applyGaugeRange(storageKey, { min: parsedMin, max: parsedMax })}
>
Aplicar
</button>
</div>
{!isRangeValid && <p className="mt-2 text-xs font-medium text-[#f59e0b]">El mínimo debe ser menor que el máximo.</p>}
</article>
)
})}
</div>
)}
</section>
<section aria-label="Tendencia seleccionada y últimas alertas" className="grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.8fr)]">
<article className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
<div className="flex flex-col gap-3 border-b border-[#2a3038] pb-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Variable seleccionada</p>
<h2 className="mt-1 truncate text-xl font-semibold tracking-tight text-[#f6f8fb]">{selectedSignal?.label ?? 'Sin variable seleccionada'}</h2>
<p className="mt-1 text-xs text-[#8b98aa]">
{selectedSignal ? `${getVariableCategory(selectedSignal.variable)} · ${selectedSignal.variable.unit || 'Sin unidad'} · ${selectedSignal.frequencyLabel}` : 'Este pozo no tiene variables habilitadas.'}
</p>
</div>
<div className="flex flex-wrap gap-2">
{selectableSignals.map((signal) => (
<button
key={signal.variable.id}
type="button"
className={`rounded-full border px-3 py-1.5 text-[11px] font-semibold transition-colors ${selectedSignal?.variable.id === signal.variable.id ? 'border-[#00b7ff]/50 bg-[#00b7ff]/15 text-[#f6f8fb]' : 'border-[#2a3038] bg-[#080b0f] text-[#9aa8ba] hover:border-[#475569]'}`}
onClick={() => setSelectedVariableId(signal.variable.id)}
>
{signal.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8da1]">Último valor</p>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-semibold tabular-nums text-[#f6f8fb]">{formatVariableValue(selectedSignal?.value ?? null)}</span>
{selectedSignal?.variable.unit && <span className="text-xs font-semibold uppercase tracking-wide text-[#8b98aa]">{selectedSignal.variable.unit}</span>}
</div>
</div>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#8b98aa]">Actualizado {formatDateTime(latestAssetData?.last_updated)}</span>
</div>
<div className="h-72 rounded-[1.25rem] border border-[#202833] bg-[#090b0f] p-3">
{selectedChartData.length === 0 ? (
<div className="flex h-full items-center justify-center text-center">
<DashboardEmptyState
icon={Activity}
label="Historial"
title="Sin historial"
description="La curva aparecerá cuando existan muestras históricas reales para esta variable."
/>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={selectedChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis dataKey="time" stroke="rgba(255,255,255,0.2)" fontSize={10} tickLine={false} axisLine={false} minTickGap={36} />
<YAxis stroke="rgba(255,255,255,0.2)" fontSize={10} tickLine={false} axisLine={false} width={36} />
<Tooltip
contentStyle={{ backgroundColor: '#11161d', border: '1px solid #2a3038', borderRadius: '10px', color: '#f6f8fb' }}
itemStyle={{ fontSize: '12px' }}
labelStyle={{ color: '#9aa8ba', fontSize: '11px' }}
/>
<Line type="linear" dataKey="value" name={selectedSignal?.label} stroke="#00b7ff" strokeWidth={2.5} dot={false} activeDot={{ r: 4, strokeWidth: 0 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</article>
<aside aria-label="Últimas alertas del pozo" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
<div className="flex items-start justify-between gap-3 border-b border-[#2a3038] pb-4">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Alarmas</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-[#f6f8fb]">Últimas alertas del pozo</h2>
</div>
<Badge className="border-destructive/30 bg-destructive/10 text-destructive">{activeAlarms.length}</Badge>
</div>
{activeAlarms.length === 0 ? (
<p className="rounded-2xl border border-[#2a3038] bg-[#080b0f] p-4 text-sm text-[#9aa8ba]">Sin alertas activas.</p>
) : (
<div className="space-y-2">
{activeAlarms.map((alarm) => (
<div key={alarm.id} className="rounded-2xl border border-destructive/20 bg-destructive/10 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold text-foreground">{alarm.variable_alias || alarm.alarm_type}</p>
<Badge className="border-destructive/30 bg-[#090b0f] text-[10px] text-destructive">{getAlarmSeverityLabel(alarm)}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{alarm.message || 'Alarma activa sin mensaje adicional.'}</p>
<p className="mt-2 text-xs text-muted-foreground">{formatDateTime(alarm.timestamp)}</p>
</div>
))}
</div>
)}
</aside>
</section>
<section aria-label="Variables de alta frecuencia" className="space-y-4 overflow-hidden rounded-[1.75rem] border border-[#2a3038] bg-[#080b0f] p-4 shadow-2xl shadow-black/30">
<div className="flex flex-col gap-1 border-b border-[#202833] pb-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#7d8da1]">Telemetría actual</p>
<h2 className="text-xl font-semibold tracking-tight text-[#f6f8fb]">Variables de alta frecuencia</h2>
</div>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#00b7ff]">
{highFrequencySignals.length} señales reales
</span>
</div>
{highFrequencySignals.length === 0 ? (
<DashboardEmptyState
icon={Database}
label="Variables"
title="Sin variables configuradas"
description="Este pozo no tiene variables habilitadas para mostrar en tiempo real."
/>
) : (
<div className="grid overflow-hidden rounded-[1.25rem] border border-[#202833] bg-[#0b0f14] sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
{highFrequencySignals.map(({ variable, label, value, sparklinePath, sparklineValues, status }) => {
const formattedValue = formatVariableValue(value)
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
return (
<div key={variable.id} aria-label={`${label}: ${formattedValue} ${variable.unit ?? ''}`} className="min-h-[104px] border-b border-r border-[#202833] bg-[#0b0f14] p-3 last:border-r-0">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-semibold uppercase tracking-[0.14em] text-[#8b98aa]">{label}</span>
<span aria-label={`Estado ${status}`} className={`h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
</div>
<div className="flex items-baseline gap-2">
<span className="truncate text-xl font-semibold tabular-nums text-[#f6f8fb]">{formattedValue}</span>
{variable.unit && <span className="text-[10px] font-semibold uppercase tracking-wide text-[#8b98aa]">{variable.unit}</span>}
</div>
{hasSparkline ? (
<svg viewBox="0 0 100 32" role="img" aria-label={`Historial de ${label}`} className="mt-2 h-8 w-full text-[#7b8798]">
<path d={sparklinePath} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</svg>
) : (
<p className="mt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-[#64748b]">Sin historial</p>
)}
</div>
)
})}
</div>
)}
</section>
<section aria-label="Variables de baja frecuencia" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Lecturas lentas</p>
<h2 className="text-xl font-semibold tracking-tight text-[#f6f8fb]">Variables de baja frecuencia</h2>
</div>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">{lowFrequencySignals.length} configuradas</span>
</div>
{lowFrequencySignals.length === 0 ? (
<p className="rounded-2xl border border-[#2a3038] bg-[#080b0f] p-4 text-sm text-[#9aa8ba]">No hay variables de baja frecuencia configuradas para este pozo.</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{lowFrequencySignals.map(({ variable, label, value, sparklinePath, sparklineValues, status, frequencyLabel }) => {
const formattedValue = formatVariableValue(value)
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
return (
<article key={variable.id} className="rounded-[1.25rem] border border-[#2a3038] bg-[#0b0f14] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[#f6f8fb]">{label}</p>
<p className="mt-1 text-xs text-[#8b98aa]">Esperada: {frequencyLabel}</p>
</div>
<span aria-label={`Estado ${status}`} className={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${variableStatusClasses[status]}`} />
</div>
<div className="mt-4 flex items-baseline gap-2">
<span className="text-2xl font-semibold tabular-nums text-[#f6f8fb]">{formattedValue}</span>
{variable.unit && <span className="text-[10px] font-semibold uppercase tracking-wide text-[#8b98aa]">{variable.unit}</span>}
</div>
<p className="mt-2 text-xs text-[#8b98aa]">Última actualización: {formatDateTime(latestAssetData?.last_updated)}</p>
{hasSparkline ? (
<svg viewBox="0 0 100 32" role="img" aria-label={`Tendencia de baja frecuencia ${label}`} className="mt-3 h-8 w-full text-[#7b8798]">
<path d={sparklinePath} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</svg>
) : (
<p className="mt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-[#64748b]">Sin historial</p>
)}
</article>
)
})}
</div>
)}
</section>
<section aria-label="Histórico del pozo" className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Histórico</p>
<h2 className="text-sm font-semibold text-foreground">Mini tendencia</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{chartGroupedData.length === 0 ? (
<div className="col-span-full">
<DashboardEmptyState
icon={Activity}
label="Histórico"
title="Sin historial"
description="La tendencia aparecerá cuando existan muestras históricas para este pozo."
/>
</div>
) : (
chartGroupedData.map((group, index) => (
<TelemetryChart
key={group.unit}
group={group}
gIdx={index}
visibleVars={visibleVars}
toggleVariable={toggleVariable}
getBgForUnit={getUnitBackgroundClass}
getColorForUnit={getUnitColorClass}
/>
))
)}
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,498 @@
import {
Activity,
AlertTriangle,
Database,
Droplets,
Gauge,
Thermometer,
Zap,
type LucideIcon,
} from 'lucide-react'
import { normalizeTelemetry, type AssetData } from './telemetry-normalization'
import type { AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
import type { EdgeVariable } from '@/features/admin/projects/api/variables-api'
export interface DynamicStat {
label: string
value: number
unit: string
icon: LucideIcon
color: string
bg: string
trend: string
}
export interface ChartGroup {
unit: string
data: Array<Record<string, number | string>>
variables: string[]
}
export interface ComplianceStatus {
samplesToday: number
isStable: boolean
latencyMs: number
}
export interface DashboardStatus {
color: string
label: string
}
export interface AssetOption {
value: string
label: string
}
export interface PanoramaMetric {
label: string
value: number | null
sub: string
icon: LucideIcon
accent?: string
}
export interface PanoramaSummary {
connectedWellCount: number
signalCount: number
metrics: PanoramaMetric[]
}
interface ConnectedWellSource {
id: string
code?: string | null
name?: string | null
}
interface HistoryRecord {
time: string
asset_id?: string
data?: Record<string, unknown>
}
interface WellAssetOptionSource {
id: string
code?: string | null
name?: string | null
}
interface WellOverviewSource extends WellAssetOptionSource {
project_id?: string | null
is_active?: boolean | null
}
export interface WellOverviewCard {
id: string
name: string
context: string
isActive: boolean
variablesCount: number
criticalCount: number
hasTelemetry: boolean
sparklineValues: number[]
lastUpdated?: string
}
export interface RecentAlertItem {
id: string
severity: 'critical' | 'warning' | 'info'
severityLabel: string
variableName: string
message: string
assetLabel: string
projectLabel: string
timestamp: string
relativeTime: string
acknowledged: boolean
}
export interface VariableCatalogItem extends EdgeVariable {
assetId: string
}
export interface VariableCategoryHealthItem {
category: string
total: number
knownStatusCount: number
normalCount: number
alarmCount: number
normalPercentage: number | null
}
export function getUnitIcon(unit?: string): LucideIcon {
const u = unit?.toUpperCase() || ''
if (u.includes('PSI')) return Gauge
if (u.includes('C')) return Thermometer
if (u.includes('%')) return Droplets
if (u.includes('L/H') || u.includes('BPD')) return Activity
return Zap
}
export function getUnitColorClass(unit?: string): string {
const u = unit?.toUpperCase() || ''
if (u.includes('PSI')) return 'text-[#00b7ff]'
if (u.includes('C')) return 'text-[#ff7a1a]'
if (u.includes('%')) return 'text-[#a8b3c4]'
if (u.includes('L/H') || u.includes('BPD')) return 'text-[#27d796]'
return 'text-primary'
}
export function getUnitBackgroundClass(unit?: string): string {
const u = unit?.toUpperCase() || ''
if (u.includes('PSI')) return 'bg-[#00b7ff]/10'
if (u.includes('C')) return 'bg-[#ff7a1a]/10'
if (u.includes('%')) return 'bg-[#a8b3c4]/10'
if (u.includes('L/H') || u.includes('BPD')) return 'bg-[#27d796]/10'
return 'bg-primary/10'
}
export function buildDynamicStats(activeAssetData: AssetData | null): DynamicStat[] {
if (!activeAssetData) return []
const normalized = normalizeTelemetry(activeAssetData.telemetry)
return Object.entries(normalized).map(([key, info]) => ({
label: key,
value: info?.value ?? 0,
unit: info?.unit || '',
icon: getUnitIcon(info.unit),
color: getUnitColorClass(info.unit),
bg: getUnitBackgroundClass(info.unit),
trend: 'Realtime',
}))
}
function hasTelemetryVariables(asset: AssetData): boolean {
return Object.keys(normalizeTelemetry(asset.telemetry)).length > 0
}
function getWellDisplayName(asset: WellAssetOptionSource): string {
return asset.name || asset.code || asset.id.slice(0, 8)
}
function getWellContext(asset: WellOverviewSource, projectName?: string | null): string {
return [asset.code, projectName].filter(Boolean).join(' · ')
}
function getAlarmSeverity(alarmType: string): RecentAlertItem['severity'] {
if (alarmType === 'ABOVE_MAX' || alarmType === 'AGENT_OFFLINE') return 'critical'
if (alarmType === 'BELOW_MIN' || alarmType === 'TELEMETRY_SILENCE') return 'warning'
return 'info'
}
function getSeverityLabel(severity: RecentAlertItem['severity']): string {
if (severity === 'critical') return 'Crítica'
if (severity === 'warning') return 'Advertencia'
return 'Info'
}
function formatRelativeTime(timestamp: string, now = new Date()): string {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return 'Sin fecha'
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.max(0, Math.floor(diffMs / 60000))
if (diffMinutes < 1) return 'Ahora'
if (diffMinutes < 60) return `Hace ${diffMinutes} min`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `Hace ${diffHours} h`
const diffDays = Math.floor(diffHours / 24)
return `Hace ${diffDays} d`
}
function findAssetDataForWell(assets: AssetData[], wellId: string): AssetData | undefined {
return assets.find((asset) => asset.asset_id === wellId)
}
function getVariableCategory(variable: Pick<EdgeVariable, 'metadata'>): string | null {
const metadata = variable.metadata
if (!metadata || typeof metadata !== 'object') return null
const categoryKeys = ['category', 'variable_category', 'variableCategory', 'dashboard_category', 'dashboardCategory']
for (const key of categoryKeys) {
const value = metadata[key]
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
}
return null
}
function getVariableValue(assetData: AssetData | undefined, alias: string): number | null {
if (!assetData) return null
const telemetry = normalizeTelemetry(assetData.telemetry)
const value = telemetry[alias]?.value
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function isValueInsideAlarmRange(variable: EdgeVariable, value: number): boolean | null {
const hasMin = typeof variable.alarm_min === 'number'
const hasMax = typeof variable.alarm_max === 'number'
if (!variable.alarm_enabled || (!hasMin && !hasMax)) return null
if (hasMin && value < variable.alarm_min!) return false
if (hasMax && value > variable.alarm_max!) return false
return true
}
function hasOpenAlarmForVariable(alarms: AlarmEvent[], variable: VariableCatalogItem): boolean {
return alarms.some((alarm) => (
!alarm.acknowledged
&& alarm.asset_id === variable.assetId
&& alarm.variable_alias === variable.alias
))
}
export function buildVariableCategoryHealth(
variables: VariableCatalogItem[],
assets: AssetData[],
alarms: AlarmEvent[],
wellAssets: ConnectedWellSource[]
): VariableCategoryHealthItem[] {
const wellIds = new Set(wellAssets.map((well) => well.id))
const assetDataById = new Map(assets.map((asset) => [asset.asset_id, asset]))
const groups = new Map<string, { total: number; knownStatusCount: number; normalCount: number; alarmCount: number }>()
variables.forEach((variable) => {
if (!variable.is_enabled) return
if (wellIds.size > 0 && !wellIds.has(variable.assetId)) return
const category = getVariableCategory(variable)
if (!category) return
const group = groups.get(category) ?? { total: 0, knownStatusCount: 0, normalCount: 0, alarmCount: 0 }
const value = getVariableValue(assetDataById.get(variable.assetId), variable.alias)
const hasOpenAlarm = hasOpenAlarmForVariable(alarms, variable)
const rangeStatus = value === null ? null : isValueInsideAlarmRange(variable, value)
const isKnown = hasOpenAlarm || rangeStatus !== null
group.total += 1
if (isKnown) group.knownStatusCount += 1
if (hasOpenAlarm || rangeStatus === false) group.alarmCount += 1
if (!hasOpenAlarm && rangeStatus === true) group.normalCount += 1
groups.set(category, group)
})
return Array.from(groups.entries())
.map(([category, group]) => ({
category,
...group,
normalPercentage: group.knownStatusCount > 0
? Math.round((group.normalCount / group.knownStatusCount) * 100)
: null,
}))
.sort((a, b) => a.category.localeCompare(b.category, 'es'))
}
function recordBelongsToWell(record: HistoryRecord, wellId: string, selectedAsset: string | 'all'): boolean {
const recordAssetId = getRecordAssetId(record)
if (recordAssetId) return recordAssetId === wellId
return selectedAsset === wellId
}
function firstTelemetryValue(telemetryData: unknown): number | null {
const normalized = normalizeTelemetry(telemetryData)
const firstValue = Object.values(normalized)[0]?.value
return typeof firstValue === 'number' && Number.isFinite(firstValue) ? firstValue : null
}
export function buildWellOverviewCards(
wellAssets: WellOverviewSource[],
assets: AssetData[],
alarms: AlarmEvent[],
history: HistoryRecord[],
selectedAsset: string | 'all',
projectName?: string | null
): WellOverviewCard[] {
return wellAssets.map((well) => {
const assetData = findAssetDataForWell(assets, well.id)
const telemetryVariables = assetData ? Object.keys(normalizeTelemetry(assetData.telemetry)) : []
const openAlarms = alarms.filter((alarm) => alarm.asset_id === well.id && !alarm.acknowledged)
const sparklineValues = history
.filter((record) => recordBelongsToWell(record, well.id, selectedAsset))
.map((record) => firstTelemetryValue(record.data))
.filter((value): value is number => value !== null)
.slice(-12)
return {
id: well.id,
name: getWellDisplayName(well),
context: getWellContext(well, projectName),
isActive: Boolean(well.is_active),
variablesCount: telemetryVariables.length,
criticalCount: openAlarms.length,
hasTelemetry: telemetryVariables.length > 0,
sparklineValues,
lastUpdated: assetData?.last_updated,
}
})
}
export function buildRecentAlerts(
alarms: AlarmEvent[],
wellAssets: WellOverviewSource[],
projectName?: string | null,
now = new Date()
): RecentAlertItem[] {
const wellById = new Map(wellAssets.map((well) => [well.id, well]))
return [...alarms]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 6)
.map((alarm) => {
const severity = getAlarmSeverity(alarm.alarm_type)
const well = alarm.asset_id ? wellById.get(alarm.asset_id) : undefined
return {
id: alarm.id,
severity,
severityLabel: getSeverityLabel(severity),
variableName: alarm.variable_alias || alarm.alarm_type,
message: alarm.message || alarm.alarm_type,
assetLabel: well ? getWellDisplayName(well) : alarm.asset_id ? alarm.asset_id.slice(0, 8) : 'Sin activo',
projectLabel: projectName || alarm.project_id || 'Sin proyecto',
timestamp: alarm.timestamp,
relativeTime: formatRelativeTime(alarm.timestamp, now),
acknowledged: Boolean(alarm.acknowledged),
}
})
}
export function countConnectedWells(assets: AssetData[], wellAssets: ConnectedWellSource[]): number {
const wellIds = new Set(wellAssets.map((asset) => asset.id))
return assets.filter((asset) => {
if (!hasTelemetryVariables(asset)) return false
if (wellIds.size === 0) return true
return wellIds.has(asset.asset_id)
}).length
}
function getWellScopedAssets(assets: AssetData[], wellAssets: ConnectedWellSource[]): AssetData[] {
const wellIds = new Set(wellAssets.map((asset) => asset.id))
if (wellIds.size === 0) return assets
return assets.filter((asset) => wellIds.has(asset.asset_id))
}
export function countTelemetryVariables(assets: AssetData[], wellAssets: ConnectedWellSource[]): number {
const variableKeys = new Set<string>()
getWellScopedAssets(assets, wellAssets).forEach((asset) => {
Object.keys(normalizeTelemetry(asset.telemetry)).forEach((key) => variableKeys.add(key))
})
return variableKeys.size
}
export function buildPanoramaSummary(
assets: AssetData[],
wellAssets: ConnectedWellSource[],
alarms: AlarmEvent[] = [],
monitoredVariableCount?: number
): PanoramaSummary {
const connectedWellCount = countConnectedWells(assets, wellAssets)
const signalCount = countTelemetryVariables(assets, wellAssets)
const variablesMetricCount = monitoredVariableCount ?? signalCount
const activeAlarmCount = alarms.filter((alarm) => !alarm.acknowledged).length
return {
connectedWellCount,
signalCount,
metrics: [
{ label: 'Pozos', value: wellAssets.length, sub: 'en operación', icon: Gauge },
{ label: 'Variables', value: variablesMetricCount, sub: 'monitoreadas', icon: Database },
{ label: 'Alertas', value: activeAlarmCount, sub: 'activas', icon: AlertTriangle, accent: 'text-[#ffb020]' },
{ label: 'Alta frec.', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-[#00b7ff]' },
{ label: 'Baja frec.', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-[#27d796]' },
{ label: 'Con falla', value: null, sub: 'sin fuente configurada', icon: AlertTriangle, accent: 'text-[#ff4655]' },
{ label: 'Producción', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-primary' },
],
}
}
function getRecordAssetId(record: HistoryRecord): unknown {
return record.asset_id || record.data?.asset_id || record.data?.id
}
function belongsToSelectedAsset(record: HistoryRecord, selectedAsset: string | 'all'): boolean {
if (selectedAsset === 'all') return true
const assetId = getRecordAssetId(record)
return !assetId || assetId === selectedAsset
}
export function groupChartData(history: HistoryRecord[], selectedAsset: string | 'all'): ChartGroup[] {
if (history.length === 0) return []
const groups: Record<string, Array<Record<string, number | string>>> = {}
const filteredHistory = history.filter((record) => belongsToSelectedAsset(record, selectedAsset))
const displayData = [...filteredHistory].reverse()
displayData.forEach((record) => {
const timestamp = new Date(record.time).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const normalized = normalizeTelemetry(record.data)
Object.entries(normalized).forEach(([key, info]) => {
const unit = info.unit || 'RAW'
if (!groups[unit]) groups[unit] = []
let entry = groups[unit].find((item) => item.time === timestamp)
if (!entry) {
entry = { time: timestamp }
groups[unit].push(entry)
}
entry[key] = info.value
})
})
return Object.entries(groups).map(([unit, data]) => {
const variables = Object.keys(data[0] || {}).filter((key) => key !== 'time')
return { unit, data, variables }
})
}
export function getComplianceStatus(
history: HistoryRecord[],
selectedAsset: string | 'all',
now = new Date(),
latencyMs = 0
): ComplianceStatus {
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
const assetHistory = history.filter((record) => belongsToSelectedAsset(record, selectedAsset))
const samplesToday = assetHistory.filter((record) => new Date(record.time) >= startOfToday).length
const latestRecord = assetHistory[0]
const lastUpdate = latestRecord ? new Date(latestRecord.time) : null
const diffSeconds = lastUpdate ? (now.getTime() - lastUpdate.getTime()) / 1000 : 9999
return {
samplesToday,
isStable: diffSeconds < 30,
latencyMs: lastUpdate ? latencyMs : 0,
}
}
export function getDashboardStatus(isConnected: boolean, isStable: boolean): DashboardStatus {
if (!isConnected) return { color: 'text-[#ff4655]', label: 'Desconectado' }
if (!isStable) return { color: 'text-[#ffb020]', label: 'Señal retrasada' }
return { color: 'text-[#27d796]', label: 'Operacional' }
}
export function buildAssetOptions(wellAssets: WellAssetOptionSource[]): AssetOption[] {
return [
{ value: 'all', label: 'Todos los pozos' },
...wellAssets.map((asset) => {
const primaryLabel = asset.name || asset.code || asset.id.slice(0, 8)
const label = asset.code && asset.name ? `${primaryLabel} (${asset.code})` : primaryLabel
return { value: asset.id, label }
}),
]
}

View File

@@ -0,0 +1,69 @@
export interface AssetData {
asset_id: string
asset: string
project_id?: string
last_updated: string
telemetry: Record<string, unknown>
}
export function getAssetProjectId(asset: AssetData): string | null {
const directProjectId = asset.project_id
if (typeof directProjectId === 'string' && directProjectId.length > 0) return directProjectId
const telemetryProjectId = asset.telemetry?.project_id
if (typeof telemetryProjectId === 'string' && telemetryProjectId.length > 0) return telemetryProjectId
return null
}
export function deduceUnit(key: string): string {
const k = key.toLowerCase()
if (k.includes('presion') || k.includes('presión') || k.includes('psi')) return 'PSI'
if (k.includes('temperatura') || k.includes('temp') || k.endsWith('_c')) return '°C'
if (k.includes('caudal') || k.includes('bpd') || k.includes('l/h')) return 'BPD'
if (k.includes('bsw') || k.includes('percent') || k.includes('%')) return '%'
if (k.includes('nivel') || k.includes('pies') || k.includes('feet')) return 'pies'
if (k.includes('volumen') || k.includes('bbl')) return 'BBL'
return 'RAW'
}
export function normalizeTelemetry(telemetryData: unknown): Record<string, { value: number; unit: string }> {
if (!telemetryData || typeof telemetryData !== 'object') return {}
const data = telemetryData as Record<string, unknown>
if (data.variables && typeof data.variables === 'object') {
const result: Record<string, { value: number; unit: string }> = {}
Object.entries(data.variables as Record<string, { value?: unknown; unit?: string }>).forEach(([key, info]) => {
result[key] = {
value: typeof info?.value === 'number' ? info.value : Number(info?.value || 0),
unit: info?.unit || 'RAW',
}
})
return result
}
if (data.values && typeof data.values === 'object') {
const result: Record<string, { value: number; unit: string }> = {}
Object.entries(data.values as Record<string, unknown>).forEach(([key, val]) => {
result[key] = {
value: typeof val === 'number' ? val : Number(val || 0),
unit: deduceUnit(key),
}
})
return result
}
const result: Record<string, { value: number; unit: string }> = {}
const ignoredKeys = ['project_id', 'device_name', 'timestamp', 'asset_id', 'asset', 'last_updated', 'telemetry', 'time', 'data', 'id']
Object.entries(data).forEach(([key, val]) => {
if (ignoredKeys.includes(key)) return
if (typeof val === 'number' || !Number.isNaN(Number(val))) {
result[key] = {
value: typeof val === 'number' ? val : Number(val),
unit: deduceUnit(key),
}
}
})
return result
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import type { EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import { buildTrendMultiSeries, buildTrendSeries, buildTrendWellOptions } from './trends-view-model'
const config: EdgeProjectFullConfig = {
project: {
id: 'project-1',
name: 'Proyecto Uno',
client: 'Cliente',
operator_name: 'Operador',
contract_number: 'C-1',
is_active: true,
created_at: '2026-06-01T00:00:00Z',
},
assets: [
{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
devices: [{
device: {
id: 'device-1',
asset_id: 'well-1',
name: 'PLC Norte',
protocol: 'MODBUS_TCP',
host: '127.0.0.1',
port: 502,
protocol_config: {},
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
variables: [{
id: 'var-1',
device_id: 'device-1',
address: '40001',
data_type: 'float32',
alias: 'PRESION_CABEZA',
unit: 'psi',
polling_interval_ms: 5000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: { label: 'Presión de cabeza', category: 'Presión' },
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
}],
}],
},
{
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-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
devices: [],
},
],
}
describe('trends view model', () => {
it('builds selectable variables only from POZO assets', () => {
const wells = buildTrendWellOptions(config)
expect(wells).toHaveLength(1)
expect(wells[0].variables[0]).toMatchObject({
assetId: 'well-1',
assetName: 'Pozo Norte',
alias: 'PRESION_CABEZA',
label: 'Presión de cabeza',
category: 'Presión',
})
})
it('extracts numeric series from nested values, nested variables, and flat payloads', () => {
const series = buildTrendSeries({
records: [
{ time: '2026-06-24T12:10:00Z', data: { PRESION_CABEZA: '103.5' } },
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101 } } },
{ time: '2026-06-24T12:05:00Z', data: { variables: { PRESION_CABEZA: { value: 102.25 } } } },
],
}, 'PRESION_CABEZA')
expect(series.map((point) => point.value)).toEqual([101, 102.25, 103.5])
})
it('returns an honest empty series when the alias has no numeric samples', () => {
const series = buildTrendSeries([
{ time: '2026-06-24T12:00:00Z', data: { values: { TEMP_MOTOR: 55 } } },
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 'offline' } } },
], 'PRESION_CABEZA')
expect(series).toEqual([])
})
it('builds aligned multi-series data without inventing missing values', () => {
const result = buildTrendMultiSeries({
records: [
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101, TEMP_MOTOR: 55 } } },
{ time: '2026-06-24T12:05:00Z', data: { values: { PRESION_CABEZA: 102 } } },
{ time: '2026-06-24T12:10:00Z', data: { values: { TEMP_MOTOR: 57 } } },
],
}, [
{ id: 'var-1', assetId: 'well-1', assetName: 'Pozo Norte', alias: 'PRESION_CABEZA', label: 'Presión de cabeza', unit: 'psi', category: 'Presión', deviceName: 'PLC Norte' },
{ id: 'var-2', assetId: 'well-1', assetName: 'Pozo Norte', alias: 'TEMP_MOTOR', label: 'Temperatura motor', unit: '°C', category: 'Temperatura', deviceName: 'PLC Norte' },
])
expect(result.series.map((serie) => ({ alias: serie.alias, points: serie.points.length }))).toEqual([
{ alias: 'PRESION_CABEZA', points: 2 },
{ alias: 'TEMP_MOTOR', points: 2 },
])
expect(result.chartData).toEqual([
expect.objectContaining({ PRESION_CABEZA: 101, TEMP_MOTOR: 55 }),
expect.objectContaining({ PRESION_CABEZA: 102 }),
expect.objectContaining({ TEMP_MOTOR: 57 }),
])
expect(result.chartData[1]).not.toHaveProperty('TEMP_MOTOR')
})
it('keeps a one-point series visible for the chart layer', () => {
const result = buildTrendMultiSeries([
{ time: '2026-06-24T12:00:00Z', data: { values: { PRESION_CABEZA: 101 } } },
], [
{ id: 'var-1', assetId: 'well-1', assetName: 'Pozo Norte', alias: 'PRESION_CABEZA', label: 'Presión de cabeza', unit: 'psi', category: 'Presión', deviceName: 'PLC Norte' },
])
expect(result.series[0].points).toHaveLength(1)
expect(result.chartData[0]).toMatchObject({ PRESION_CABEZA: 101 })
})
})

View File

@@ -0,0 +1,199 @@
import type { EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import type { EdgeVariable } from '@/features/admin/projects/api/variables-api'
import type { TelemetryHistoryRecord, TelemetryHistoryResponse } from '../api/dashboard-api'
import { deriveCategory } from './variables-view-model'
export interface TrendVariableOption {
id: string
assetId: string
assetName: string
alias: string
label: string
unit?: string
category: string
deviceName: string
pollingIntervalMs?: number
}
export interface TrendWellOption {
id: string
name: string
code: string
variables: TrendVariableOption[]
}
export interface TrendSeriesPoint {
time: string
label: string
value: number
rawValue: unknown
}
export interface TrendMultiSeries {
alias: string
label: string
unit?: string
category: string
deviceName: string
pollingIntervalMs?: number
color: string
points: TrendSeriesPoint[]
}
export interface TrendChartPoint {
time: string
label: string
[alias: string]: string | number | null
}
export interface TrendMultiSeriesResult {
chartData: TrendChartPoint[]
series: TrendMultiSeries[]
}
const TREND_COLORS = ['#22d3ee', '#fb923c', '#34d399', '#a78bfa', '#f472b6', '#facc15', '#60a5fa', '#f87171']
function getStringMetadata(metadata: Record<string, unknown> | undefined, keys: string[]) {
if (!metadata) return null
for (const key of keys) {
const value = metadata[key]
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
}
return null
}
function getVariableLabel(variable: EdgeVariable) {
return getStringMetadata(variable.metadata, ['label', 'name', 'nombre']) ?? variable.alias
}
export function buildTrendWellOptions(config: EdgeProjectFullConfig | null): TrendWellOption[] {
if (!config) return []
return config.assets
.filter(({ asset }) => asset.asset_type === 'POZO')
.map(({ asset, devices }) => {
const assetName = asset.name || asset.code || 'Pozo sin nombre'
const variables = devices.flatMap(({ device, variables }) => variables.map((variable) => ({
id: variable.id,
assetId: asset.id,
assetName,
alias: variable.alias,
label: getVariableLabel(variable),
unit: variable.unit,
category: deriveCategory(variable.alias, variable.metadata),
deviceName: device.name,
pollingIntervalMs: variable.polling_interval_ms,
})))
return {
id: asset.id,
name: assetName,
code: asset.code,
variables,
}
})
.filter((well) => well.variables.length > 0)
}
function unwrapRecords(history: TelemetryHistoryResponse | TelemetryHistoryRecord[] | null | undefined) {
if (!history) return []
if (Array.isArray(history)) return history
return history.records ?? []
}
function readAliasValue(payload: unknown, alias: string): unknown {
if (!payload || typeof payload !== 'object') return undefined
const data = payload as Record<string, unknown>
const variables = data.variables
if (variables && typeof variables === 'object' && alias in variables) {
const raw = (variables as Record<string, unknown>)[alias]
if (raw && typeof raw === 'object' && 'value' in raw) return (raw as Record<string, unknown>).value
return raw
}
const values = data.values
if (values && typeof values === 'object' && alias in values) {
return (values as Record<string, unknown>)[alias]
}
if (alias in data) return data[alias]
return undefined
}
function getRecordValue(record: TelemetryHistoryRecord, alias: string) {
const fromData = readAliasValue(record.data, alias)
if (fromData !== undefined) return fromData
const fromTelemetry = readAliasValue(record.telemetry, alias)
if (fromTelemetry !== undefined) return fromTelemetry
return readAliasValue(record, alias)
}
function getRecordTime(record: TelemetryHistoryRecord) {
const candidate = record.time ?? record.timestamp ?? record.created_at
return typeof candidate === 'string' ? candidate : ''
}
function formatPointLabel(timestamp: string) {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return timestamp || 'Sin hora'
return new Intl.DateTimeFormat('es-CO', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: '2-digit',
}).format(date)
}
export function buildTrendSeries(history: TelemetryHistoryResponse | TelemetryHistoryRecord[] | null | undefined, alias: string): TrendSeriesPoint[] {
if (!alias) return []
return unwrapRecords(history)
.map((record) => {
const rawValue = getRecordValue(record, alias)
const value = typeof rawValue === 'number' ? rawValue : Number(rawValue)
const time = getRecordTime(record)
if (!time || Number.isNaN(value)) return null
return {
time,
label: formatPointLabel(time),
value,
rawValue,
}
})
.filter((point): point is TrendSeriesPoint => Boolean(point))
.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
}
export function buildTrendMultiSeries(
history: TelemetryHistoryResponse | TelemetryHistoryRecord[] | null | undefined,
variables: TrendVariableOption[],
): TrendMultiSeriesResult {
const series = variables.map((variable, index) => ({
alias: variable.alias,
label: variable.label,
unit: variable.unit,
category: variable.category,
deviceName: variable.deviceName,
pollingIntervalMs: variable.pollingIntervalMs,
color: TREND_COLORS[index % TREND_COLORS.length],
points: buildTrendSeries(history, variable.alias),
}))
const rowsByTime = new Map<string, TrendChartPoint>()
for (const variableSeries of series) {
for (const point of variableSeries.points) {
const existing = rowsByTime.get(point.time) ?? { time: point.time, label: point.label }
existing[variableSeries.alias] = point.value
rowsByTime.set(point.time, existing)
}
}
const chartData = Array.from(rowsByTime.values()).sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
return { chartData, series }
}

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest'
import type { AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
import type { EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import { buildVariableExplorerRows, filterVariableExplorerRows } from './variables-view-model'
const baseConfig: EdgeProjectFullConfig = {
project: {
id: 'project-1',
name: 'Proyecto Uno',
client: 'Cliente',
operator_name: 'Operador',
contract_number: 'C-1',
is_active: true,
created_at: '2026-06-01T00:00:00Z',
},
assets: [
{
asset: {
id: 'well-1',
project_id: 'project-1',
code: 'PZ-01',
name: 'Pozo Norte',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
devices: [
{
device: {
id: 'device-1',
asset_id: 'well-1',
name: 'PLC Norte',
protocol: 'MODBUS_TCP',
host: '127.0.0.1',
port: 502,
protocol_config: {},
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
variables: [
{
id: 'var-1',
device_id: 'device-1',
address: '40001',
data_type: 'float32',
alias: 'PRESION_CABEZA',
unit: 'psi',
polling_interval_ms: 5000,
alarm_min: 100,
alarm_max: 500,
alarm_enabled: true,
byte_order: 'ABCD',
metadata: { label: 'Presión de cabeza', category: 'Presión' },
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
{
id: 'var-2',
device_id: 'device-1',
address: '40003',
data_type: 'float32',
alias: 'TEMP_MOTOR',
polling_interval_ms: 60000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: {},
is_enabled: false,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
],
},
],
},
{
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-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
devices: [
{
device: {
id: 'device-2',
asset_id: 'tank-1',
name: 'PLC Tanque',
protocol: 'MODBUS_TCP',
host: '127.0.0.1',
port: 503,
protocol_config: {},
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
variables: [
{
id: 'var-tank',
device_id: 'device-2',
address: '40001',
data_type: 'float32',
alias: 'NIVEL_TANQUE',
polling_interval_ms: 5000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: {},
is_enabled: true,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
],
},
],
},
],
}
describe('variables view model', () => {
it('flattens only POZO variables and joins latest telemetry plus alarms', () => {
const alarms: AlarmEvent[] = [{
id: 'alarm-1',
project_id: 'project-1',
asset_id: 'well-1',
variable_alias: 'PRESION_CABEZA',
current_value: 550,
alarm_max: 500,
alarm_type: 'ABOVE_MAX',
acknowledged: false,
timestamp: '2026-06-24T12:30:00Z',
}]
const rows = buildVariableExplorerRows(baseConfig, [{
asset_id: 'well-1',
asset: 'Pozo Norte',
last_updated: '2026-06-24T12:30:00Z',
telemetry: { variables: { PRESION_CABEZA: { value: 550.1234, quality: 'GOOD' } } },
}], alarms)
expect(rows).toHaveLength(2)
expect(rows[0]).toMatchObject({
tag: 'PRESION_CABEZA',
variable: 'Presión de cabeza',
well: 'Pozo Norte',
category: 'Presión',
frequency: '5 s',
value: '550.123 psi',
quality: 'GOOD',
status: 'alarm',
statusLabel: 'Alarma activa',
})
expect(rows.some((row) => row.tag === 'NIVEL_TANQUE')).toBe(false)
})
it('uses honest no-data states when latest telemetry is absent', () => {
const rows = buildVariableExplorerRows(baseConfig, [], [])
expect(rows[0]).toMatchObject({ value: 'Sin dato', quality: 'Sin dato', status: 'no-data', updatedAtLabel: 'Sin dato' })
expect(rows[1]).toMatchObject({ value: 'Sin dato', status: 'disabled' })
})
it('filters rows by real search and select values', () => {
const rows = buildVariableExplorerRows(baseConfig, [], [])
const filtered = filterVariableExplorerRows(rows, {
search: 'presión',
well: 'Pozo Norte',
category: 'Presión',
frequency: '5 s',
status: 'no-data',
})
expect(filtered.map((row) => row.tag)).toEqual(['PRESION_CABEZA'])
})
})

View File

@@ -0,0 +1,176 @@
import type { AlarmEvent } from '@/features/admin/alerts/api/alerts-api'
import type { EdgeProjectFullConfig } from '@/features/admin/projects/api/projects-api'
import type { AssetData } from './telemetry-normalization'
export type VariableStatus = 'disabled' | 'alarm' | 'out-of-range' | 'normal' | 'no-data'
export interface VariableExplorerRow {
id: string
assetId: string
tag: string
variable: string
well: string
category: string
frequency: string
frequencyMs: number | null
value: string
quality: string
status: VariableStatus
statusLabel: string
updatedAt: string
updatedAtLabel: string
deviceName: string
}
export interface VariableExplorerFilters {
search: string
well: string
category: string
frequency: string
status: string
}
type TelemetryReading = {
value: unknown
quality?: unknown
}
function getStringMetadata(metadata: Record<string, unknown> | undefined, keys: string[]) {
if (!metadata) return null
for (const key of keys) {
const value = metadata[key]
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
}
return null
}
function getTelemetryReading(assetTelemetry: AssetData | undefined, alias: string): TelemetryReading | null {
const telemetry = assetTelemetry?.telemetry
if (!telemetry) return null
const variables = telemetry.variables
if (variables && typeof variables === 'object' && alias in variables) {
const raw = (variables as Record<string, unknown>)[alias]
if (raw && typeof raw === 'object' && 'value' in raw) return raw as TelemetryReading
return { value: raw }
}
const values = telemetry.values
if (values && typeof values === 'object' && alias in values) {
return { value: (values as Record<string, unknown>)[alias] }
}
if (alias in telemetry) return { value: telemetry[alias] }
return null
}
function formatValue(value: unknown, unit?: string) {
if (value === null || value === undefined || value === '') return 'Sin dato'
const formatted = typeof value === 'number' ? Number(value.toFixed(3)).toString() : String(value)
return unit ? `${formatted} ${unit}` : formatted
}
export function formatPollingInterval(ms: number | null | undefined) {
if (!ms || ms <= 0) return 'Sin dato'
if (ms < 1000) return `${ms} ms`
if (ms % 60000 === 0) return `${ms / 60000} min`
if (ms % 1000 === 0) return `${ms / 1000} s`
return `${ms} ms`
}
function formatTimestamp(timestamp: string | null | undefined) {
if (!timestamp) return 'Sin dato'
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return 'Sin dato'
return new Intl.DateTimeFormat('es-CO', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function getStatus(isEnabled: boolean, alarm: AlarmEvent | undefined, variable: { alarm_enabled: boolean; alarm_min?: number; alarm_max?: number }, value: unknown): VariableStatus {
if (!isEnabled) return 'disabled'
if (alarm && !alarm.acknowledged) return 'alarm'
if (value === null || value === undefined || value === '') return 'no-data'
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isNaN(numericValue) && variable.alarm_enabled) {
if (typeof variable.alarm_min === 'number' && numericValue < variable.alarm_min) return 'out-of-range'
if (typeof variable.alarm_max === 'number' && numericValue > variable.alarm_max) return 'out-of-range'
}
return 'normal'
}
function getStatusLabel(status: VariableStatus) {
const labels: Record<VariableStatus, string> = {
disabled: 'Deshabilitada',
alarm: 'Alarma activa',
'out-of-range': 'Fuera de rango',
normal: 'Normal',
'no-data': 'Sin dato',
}
return labels[status]
}
export function deriveCategory(alias: string, metadata: Record<string, unknown> | undefined) {
const metadataCategory = getStringMetadata(metadata, ['category', 'categoria', 'group', 'grupo'])
if (metadataCategory) return metadataCategory
const [prefix] = alias.split(/[._-]/)
return prefix && prefix !== alias ? prefix.toUpperCase() : 'General'
}
export function buildVariableExplorerRows(config: EdgeProjectFullConfig | null, telemetry: AssetData[], alarms: AlarmEvent[]): VariableExplorerRow[] {
if (!config) return []
const telemetryByAssetId = new Map(telemetry.map((asset) => [asset.asset_id, asset]))
const alarmsByAssetAndAlias = new Map(
alarms
.filter((alarm) => alarm.asset_id && alarm.variable_alias)
.map((alarm) => [`${alarm.asset_id}:${alarm.variable_alias}`, alarm])
)
return config.assets
.filter(({ asset }) => asset.asset_type === 'POZO')
.flatMap(({ asset, devices }) => {
const assetTelemetry = telemetryByAssetId.get(asset.id)
return devices.flatMap(({ device, variables }) => variables.map((variable) => {
const reading = getTelemetryReading(assetTelemetry, variable.alias)
const alarm = alarmsByAssetAndAlias.get(`${asset.id}:${variable.alias}`)
const status = getStatus(variable.is_enabled && device.is_enabled && asset.is_active, alarm, variable, reading?.value)
const metadataName = getStringMetadata(variable.metadata, ['label', 'name', 'nombre'])
return {
id: variable.id,
assetId: asset.id,
tag: variable.alias,
variable: metadataName ?? variable.alias,
well: asset.name || asset.code || 'Pozo sin nombre',
category: deriveCategory(variable.alias, variable.metadata),
frequency: formatPollingInterval(variable.polling_interval_ms),
frequencyMs: variable.polling_interval_ms ?? null,
value: formatValue(reading?.value, variable.unit),
quality: typeof reading?.quality === 'string' && reading.quality.trim().length > 0 ? reading.quality : 'Sin dato',
status,
statusLabel: getStatusLabel(status),
updatedAt: assetTelemetry?.last_updated ?? '',
updatedAtLabel: formatTimestamp(assetTelemetry?.last_updated),
deviceName: device.name,
}
}))
})
}
export function filterVariableExplorerRows(rows: VariableExplorerRow[], filters: VariableExplorerFilters) {
const search = filters.search.trim().toLowerCase()
return rows.filter((row) => {
const matchesSearch = !search || row.tag.toLowerCase().includes(search) || row.variable.toLowerCase().includes(search)
const matchesWell = filters.well === 'all' || row.well === filters.well
const matchesCategory = filters.category === 'all' || row.category === filters.category
const matchesFrequency = filters.frequency === 'all' || row.frequency === filters.frequency
const matchesStatus = filters.status === 'all' || row.status === filters.status
return matchesSearch && matchesWell && matchesCategory && matchesFrequency && matchesStatus
})
}

View File

@@ -1,13 +1,29 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { apiClient } from '@/lib/api/client' import { apiClient } from '@/lib/api/client'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { deleteProject } from './projects-api' import { deleteProject, getProjectFullConfig } from './projects-api'
vi.mock('@/lib/api/client', () => ({ vi.mock('@/lib/api/client', () => ({
apiClient: vi.fn(), apiClient: vi.fn(),
})) }))
describe('projects-api', () => { describe('projects-api', () => {
it('gets full Edge project config through the typed full endpoint', async () => {
useAuthStore.setState({ accessToken: 'token', token: 'token' } as any, true)
vi.mocked(apiClient).mockResolvedValue({
project: { id: 'project-1', name: 'Proyecto Uno' },
assets: [{
asset: { id: 'asset-1', asset_code: 'PZ-01', asset_name: 'Pozo Norte', asset_type: 'POZO' },
devices: [],
}],
})
const config = await getProjectFullConfig('project-1')
expect(apiClient).toHaveBeenCalledWith('/edge/projects/project-1/full', { token: 'token' })
expect(config.assets[0].asset).toMatchObject({ code: 'PZ-01', name: 'Pozo Norte' })
})
it('deletes an Edge project through the project endpoint', async () => { it('deletes an Edge project through the project endpoint', async () => {
useAuthStore.setState({ accessToken: 'token', token: 'token' } as any, true) useAuthStore.setState({ accessToken: 'token', token: 'token' } as any, true)
vi.mocked(apiClient).mockResolvedValue(undefined) vi.mocked(apiClient).mockResolvedValue(undefined)

View File

@@ -1,5 +1,8 @@
import { apiClient } from '@/lib/api/client' import { apiClient } from '@/lib/api/client'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import type { EdgeDevice } from './devices-api'
import type { EdgeVariable } from './variables-api'
import type { EdgeAsset } from './wells-api'
export interface EdgeProject { export interface EdgeProject {
id: string id: string
@@ -15,6 +18,36 @@ export interface EdgeProject {
created_at: string created_at: string
} }
type EdgeAssetFullApiResponse = Omit<EdgeAsset, 'code' | 'name'> & {
code?: string
name?: string
asset_code?: string
asset_name?: string
}
export interface EdgeDeviceFullConfig {
device: EdgeDevice
variables: EdgeVariable[]
}
export interface EdgeAssetFullConfig {
asset: EdgeAsset
devices: EdgeDeviceFullConfig[]
}
export interface EdgeProjectFullConfig {
project: EdgeProject
assets: EdgeAssetFullConfig[]
}
interface EdgeProjectFullConfigApiResponse {
project: EdgeProject
assets: Array<{
asset: EdgeAssetFullApiResponse
devices: EdgeDeviceFullConfig[]
}>
}
export interface CreateProjectRequest { export interface CreateProjectRequest {
name: string name: string
client: string client: string
@@ -28,6 +61,23 @@ export async function getProject(projectId: string) {
return apiClient<EdgeProject>(`/edge/projects/${projectId}`, { token }) return apiClient<EdgeProject>(`/edge/projects/${projectId}`, { token })
} }
export async function getProjectFullConfig(projectId: string): Promise<EdgeProjectFullConfig> {
const token = useAuthStore.getState().token
const config = await apiClient<EdgeProjectFullConfigApiResponse>(`/edge/projects/${projectId}/full`, { token })
return {
project: config.project,
assets: config.assets.map(({ asset, devices }) => ({
asset: {
...asset,
code: asset.code ?? asset.asset_code ?? '',
name: asset.name ?? asset.asset_name ?? '',
},
devices,
})),
}
}
export async function listProjects() { export async function listProjects() {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
return apiClient<EdgeProject[]>('/edge/projects', { token }) return apiClient<EdgeProject[]>('/edge/projects', { token })

View File

@@ -73,6 +73,11 @@ export async function getAsset(assetId: string) {
return normalizeEdgeAsset(asset) return normalizeEdgeAsset(asset)
} }
export async function getAsset(assetId: string) {
const token = useAuthStore.getState().accessToken
return apiClient<EdgeAsset>(`/edge/assets/${assetId}`, { token })
}
export async function createAsset(data: CreateAssetRequest) { export async function createAsset(data: CreateAssetRequest) {
const token = useAuthStore.getState().accessToken const token = useAuthStore.getState().accessToken
const asset = await apiClient<EdgeAssetApiResponse>(`/edge/assets`, { const asset = await apiClient<EdgeAssetApiResponse>(`/edge/assets`, {

View File

@@ -40,15 +40,36 @@ function getAgentStatus(project: EdgeProject | null, now = Date.now()): AgentSta
function AgentHealthSummary({ project }: AgentHealthSummaryProps) { function AgentHealthSummary({ project }: AgentHealthSummaryProps) {
const status = getAgentStatus(project) const status = getAgentStatus(project)
const hostname = project?.last_health_report?.agent_hostname || 'Agente sin identificar' const hostname = project?.last_health_report?.agent_hostname || 'Agente sin identificar'
const badgeClass = status.variant === 'default'
? 'border-[#27d796]/30 bg-[#27d796]/15 text-[#27d796] shadow-none'
: status.variant === 'destructive'
? 'border-[#ff4655]/30 bg-[#ff4655]/15 text-[#ff4655]'
: 'border-[#9aa8ba]/30 bg-[#9aa8ba]/10 text-[#9aa8ba]'
return ( return (
<section className="border-b bg-primary/5 px-4 py-3 md:px-6" aria-label="Salud del agente"> <section className="border-t border-[#2a3038] pt-5 text-white" aria-label="Salud del agente">
<div className="flex flex-wrap items-center gap-3 text-xs"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Activity className="h-3.5 w-3.5 text-primary" aria-hidden="true" /> <div className="flex items-center gap-3">
<span className="font-medium text-muted-foreground">Agente:</span> <span className="flex h-9 w-9 items-center justify-center rounded-xl border border-[#ff7a1a]/30 bg-[#ff7a1a]/10 text-[#ff7a1a]">
<span className="font-semibold text-foreground">{hostname}</span> <Activity className="h-4 w-4" aria-hidden="true" />
<Badge variant={status.variant}>{status.label}</Badge> </span>
<span className="text-muted-foreground">Último reporte: {status.lastSeenLabel}</span> <div className="space-y-0.5">
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Estado del agente</p>
<p className="text-sm font-semibold text-white">Enlace edge</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full border border-[#2a3038] bg-[#0b0f14] px-3 py-1 font-medium text-white">
Agente: {hostname}
</span>
<Badge variant={status.variant} className={badgeClass}>
{status.label}
</Badge>
<span className="rounded-full border border-[#2a3038] bg-[#0b0f14] px-3 py-1 text-[#9aa8ba]">
Último reporte: {status.lastSeenLabel}
</span>
</div>
</div> </div>
</section> </section>
) )

View File

@@ -88,6 +88,12 @@ describe('AgentLogViewer', () => {
await flushEffects() await flushEffects()
expect(mockedApiClient).toHaveBeenCalledWith('/edge/agents/logs?project_id=project-1&limit=100') expect(mockedApiClient).toHaveBeenCalledWith('/edge/agents/logs?project_id=project-1&limit=100')
expect(container.textContent).toContain('Bitácora SCADA del agente')
expect(container.textContent).toContain('Registro de eventos')
expect(container.textContent).toContain('Filtro operativo')
expect(container.textContent).toContain('Nivel SCADA')
expect(container.textContent).toContain('Pozo vinculado')
expect(container.textContent).toContain('Eventos del agente')
expect(container.querySelector('time')?.getAttribute('dateTime')).toBe('2026-06-21T12:34:56Z') expect(container.querySelector('time')?.getAttribute('dateTime')).toBe('2026-06-21T12:34:56Z')
expect(container.textContent).toContain('ERROR') expect(container.textContent).toContain('ERROR')
expect(container.textContent).toContain('system') expect(container.textContent).toContain('system')
@@ -220,7 +226,8 @@ describe('AgentLogViewer', () => {
vi.advanceTimersByTime(10000) vi.advanceTimersByTime(10000)
}) })
expect(mockedApiClient).toHaveBeenCalledTimes(1) expect(mockedApiClient).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('Actualización pausada') expect(container.textContent).toContain('Streaming pausado')
expect(container.textContent).toContain('El intervalo automático está detenido')
const refresh = Array.from(container.querySelectorAll('button')).find((button) => const refresh = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Actualizar') button.textContent?.includes('Actualizar')

View File

@@ -56,7 +56,7 @@ function containsExactPhrase(rawValue: string, rawToken: string) {
let startIndex = value.indexOf(token) let startIndex = value.indexOf(token)
while (startIndex >= 0) { while (startIndex >= 0) {
const previous = startIndex > 0 ? value[startIndex - 1] : '' const previous = startIndex > 0 ? value[startIndex - 1] ?? '' : ''
const nextIndex = startIndex + token.length const nextIndex = startIndex + token.length
const next = value[nextIndex] ?? '' const next = value[nextIndex] ?? ''
const hasValidStart = startIndex === 0 || !isAlphaNumeric(previous) const hasValidStart = startIndex === 0 || !isAlphaNumeric(previous)
@@ -126,7 +126,7 @@ function getLevelTone(level: string) {
if (normalized === 'ERROR') return 'bg-destructive/10 text-destructive border-destructive/30' if (normalized === 'ERROR') return 'bg-destructive/10 text-destructive border-destructive/30'
if (normalized === 'WARN') return 'bg-warning/10 text-warning border-warning/30' if (normalized === 'WARN') return 'bg-warning/10 text-warning border-warning/30'
if (normalized === 'DEBUG') return 'bg-secondary text-muted-foreground border-border' if (normalized === 'DEBUG') return 'border-[#2a3038] bg-[#0f1318] text-[#9aa8ba]'
return 'bg-success/10 text-success border-success/30' return 'bg-success/10 text-success border-success/30'
} }
@@ -213,57 +213,77 @@ export default function AgentLogViewer({ projectId }: { projectId: string }) {
const isInitialLoading = loading && logs.length === 0 const isInitialLoading = loading && logs.length === 0
return ( return (
<section className="mt-6 flex h-[460px] flex-col overflow-hidden rounded-xl border border-border/50 bg-card/80" aria-label="Logs operativos del agente"> <section className="mt-6 flex h-[460px] flex-col overflow-hidden rounded-2xl border border-[#2a3038] bg-[#15191e] shadow-2xl shadow-black/25" aria-label="Logs operativos del agente">
<div className="flex flex-col gap-3 border-b border-border bg-card px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="border-b border-[#2a3038] bg-[#171b20] px-4 py-3">
<div className="flex items-start gap-2"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<ScrollText className="mt-0.5 h-4 w-4 text-primary" aria-hidden="true" /> <div className="flex items-start gap-3">
<div className="rounded-xl border border-[#ff7a1a]/30 bg-[#ff7a1a]/10 p-2 text-[#ff7a1a] shadow-inner shadow-black/20" aria-hidden="true">
<ScrollText className="h-4 w-4" />
</div>
<div> <div>
<h3 className="text-sm font-semibold">Logs operativos del agente</h3> <p className="mb-1 text-[10px] font-bold uppercase tracking-[0.2em] text-[#ff7a1a]">
<p className="text-xs text-muted-foreground"> Registro de eventos
</p>
<h3 className="text-sm font-semibold text-white">Bitácora SCADA del agente</h3>
<p className="text-xs text-[#9aa8ba]">
Consulta acotada al proyecto actual con actualización automática cada 10 segundos. Consulta acotada al proyecto actual con actualización automática cada 10 segundos.
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 rounded-xl border border-[#2a3038] bg-[#0f1318] p-2" aria-label="Filtro operativo">
<span className="px-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">Filtro operativo</span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<label className="relative flex items-center"> <label className="relative flex items-center">
<Search className="pointer-events-none absolute left-2 h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" /> <span className="sr-only">Buscar en logs</span>
<Search className="pointer-events-none absolute left-2 h-3.5 w-3.5 text-[#6f7b8c]" aria-hidden="true" />
<input <input
aria-label="Buscar en logs" aria-label="Buscar en logs"
value={search} value={search}
onInput={(event) => setSearch(event.currentTarget.value)} onInput={(event) => setSearch(event.currentTarget.value)}
placeholder="Buscar mensaje" placeholder="Buscar evento"
className="h-8 w-40 rounded-lg border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-2 focus:ring-primary/30" className="h-8 w-40 rounded-lg border border-[#2a3038] bg-[#090b0f] pl-7 pr-2 text-xs text-white outline-none transition-all placeholder:text-[#4e5a68] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
/> />
</label> </label>
<label className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#9aa8ba]">
Nivel SCADA
<select <select
aria-label="Filtrar logs por nivel" aria-label="Filtrar logs por nivel"
value={level} value={level}
onChange={(event) => setLevel(event.target.value)} onChange={(event) => setLevel(event.target.value)}
className="h-8 rounded-lg border border-border bg-background px-2 text-xs outline-none focus:ring-2 focus:ring-primary/30" className="h-8 rounded-lg border border-[#2a3038] bg-[#090b0f] px-2 text-xs normal-case tracking-normal text-white outline-none transition-all focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40 [color-scheme:dark]"
> >
{LEVEL_OPTIONS.map((option) => ( {LEVEL_OPTIONS.map((option) => (
<option key={option.value || 'all-levels'} value={option.value}>{option.label}</option> <option key={option.value || 'all-levels'} value={option.value}>{option.label}</option>
))} ))}
</select> </select>
</label>
<label className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#9aa8ba]">
Pozo vinculado
<select <select
aria-label="Filtrar logs por pozo" aria-label="Filtrar logs por pozo"
value={wellId} value={wellId}
onChange={(event) => setWellId(event.target.value)} onChange={(event) => setWellId(event.target.value)}
className="h-8 rounded-lg border border-border bg-background px-2 text-xs outline-none focus:ring-2 focus:ring-primary/30" className="h-8 rounded-lg border border-[#2a3038] bg-[#090b0f] px-2 text-xs normal-case tracking-normal text-white outline-none transition-all focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40 [color-scheme:dark]"
> >
<option value="">Todos los pozos</option> <option value="">Todos los pozos</option>
{wells.map((well) => ( {wells.map((well) => (
<option key={well.id} value={well.id}>{getWellLabel(well)}</option> <option key={well.id} value={well.id}>{getWellLabel(well)}</option>
))} ))}
</select> </select>
</label>
<button <button
type="button" type="button"
onClick={() => setPaused((current) => !current)} onClick={() => setPaused((current) => !current)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-2 text-xs transition-colors hover:bg-secondary" className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg border px-2 text-xs font-medium transition-colors',
paused
? 'border-warning/40 bg-warning/10 text-warning hover:bg-warning/20'
: 'border-[#2a3038] bg-[#101419] text-[#d7dee8] hover:border-[#ff7a1a]/40 hover:bg-[#ff7a1a]/10 hover:text-white'
)}
aria-pressed={paused} aria-pressed={paused}
> >
{paused ? <Play className="h-3.5 w-3.5" aria-hidden="true" /> : <Pause className="h-3.5 w-3.5" aria-hidden="true" />} {paused ? <Play className="h-3.5 w-3.5" aria-hidden="true" /> : <Pause className="h-3.5 w-3.5" aria-hidden="true" />}
@@ -273,47 +293,54 @@ export default function AgentLogViewer({ projectId }: { projectId: string }) {
<button <button
type="button" type="button"
onClick={fetchLogs} onClick={fetchLogs}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-2 text-xs transition-colors hover:bg-secondary" className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-[#ff7a1a]/40 bg-[#ff7a1a]/15 px-2 text-xs font-semibold text-[#ff7a1a] transition-colors hover:bg-[#ff7a1a]/25"
aria-label="Actualizar logs del proyecto" aria-label="Actualizar logs del proyecto"
> >
<RefreshCw className={cn('h-3.5 w-3.5 text-muted-foreground', loading && 'animate-spin')} aria-hidden="true" /> <RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} aria-hidden="true" />
Actualizar Actualizar
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
{paused && ( {paused && (
<div className="border-b border-warning/20 bg-warning/10 px-4 py-2 text-xs text-warning"> <div className="border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning">
Actualización pausada. Podés actualizar manualmente sin reactivar el intervalo. <span className="font-semibold">Streaming pausado.</span> El intervalo automático está detenido; podés actualizar manualmente sin reactivarlo.
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto bg-black/20 p-4 font-mono text-[11px] scrollbar-thin"> <div className="flex-1 overflow-y-auto bg-[#090b0f] p-4 font-mono text-[11px] scrollbar-thin">
{isInitialLoading ? ( {isInitialLoading ? (
<LogState icon={<RefreshCw className="h-8 w-8 animate-spin" />} title="Cargando logs del proyecto" description="Estamos consultando los últimos eventos del agente." /> <LogState icon={<RefreshCw className="h-8 w-8 animate-spin" />} eyebrow="Sincronizando canal" title="Cargando logs del proyecto" description="Estamos consultando los últimos eventos del agente edge." />
) : error && logs.length === 0 ? ( ) : error && logs.length === 0 ? (
<LogState icon={<AlertCircle className="h-8 w-8" />} title="No se pudieron cargar los logs del proyecto" description="Revisá la conexión o intentá actualizar nuevamente." tone="error" /> <LogState icon={<AlertCircle className="h-8 w-8" />} eyebrow="Canal no disponible" title="No se pudieron cargar los logs del proyecto" description="Revisá la conexión o intentá actualizar nuevamente." tone="error" />
) : visibleLogs.length === 0 ? ( ) : visibleLogs.length === 0 ? (
<LogState <LogState
icon={<Clock className="h-8 w-8" />} icon={<Clock className="h-8 w-8" />}
eyebrow={hasActiveFilters ? 'Sin coincidencias' : 'Sin eventos recientes'}
title={hasActiveFilters ? 'No hay logs que coincidan con los filtros' : 'No hay logs recientes para este proyecto'} title={hasActiveFilters ? 'No hay logs que coincidan con los filtros' : 'No hay logs recientes para este proyecto'}
description={hasActiveFilters ? 'Probá ajustar la búsqueda, el nivel o el pozo.' : 'Cuando el agente envíe eventos, van a aparecer acá.'} description={hasActiveFilters ? 'Probá ajustar la búsqueda, el nivel SCADA o el pozo vinculado.' : 'Cuando el agente envíe eventos operativos, van a aparecer acá.'}
/> />
) : ( ) : (
<div className="space-y-2" role="list" aria-label="Eventos del agente"> <div className="space-y-2" role="list" aria-label="Eventos del agente">
<div className="flex items-center justify-between rounded-lg border border-[#2a3038] bg-[#0f1318] px-3 py-2 text-[#d7dee8]">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#ff7a1a]">Eventos del agente</span>
<span className="text-[10px] text-[#9aa8ba]">{visibleLogs.length} registros</span>
</div>
{visibleLogs.map((log) => { {visibleLogs.map((log) => {
const rawDate = getLogDate(log) const rawDate = getLogDate(log)
return ( return (
<article key={log.id} role="listitem" className="grid gap-2 rounded-lg border border-white/5 bg-background/40 p-3 leading-relaxed transition-colors hover:bg-background/70 md:grid-cols-[150px_72px_120px_1fr]"> <article key={log.id} role="listitem" className="grid gap-2 rounded-xl border border-[#2a3038] bg-[#0f1318] p-3 leading-relaxed text-[#d7dee8] shadow-sm shadow-black/20 transition-colors hover:border-[#ff7a1a]/35 hover:bg-[#15191e] md:grid-cols-[150px_72px_120px_1fr]">
<time className="text-muted-foreground" dateTime={rawDate || undefined}> <time className="text-[#9aa8ba]" dateTime={rawDate || undefined}>
{formatLogDate(rawDate)} {formatLogDate(rawDate)}
</time> </time>
<span className={cn('inline-flex w-fit items-center rounded border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide', getLevelTone(log.level))}> <span className={cn('inline-flex w-fit items-center rounded border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide', getLevelTone(log.level))}>
{log.level || 'INFO'} {log.level || 'INFO'}
</span> </span>
<span className="font-semibold text-primary/80">{log.category || 'general'}</span> <span className="font-semibold text-[#ff7a1a]/80">{log.category || 'general'}</span>
<span className="break-words text-foreground/90">{log.message || 'Mensaje no disponible'}</span> <span className="break-words text-white">{log.message || 'Mensaje no disponible'}</span>
</article> </article>
) )
})} })}
@@ -326,22 +353,25 @@ export default function AgentLogViewer({ projectId }: { projectId: string }) {
function LogState({ function LogState({
icon, icon,
eyebrow,
title, title,
description, description,
tone = 'muted', tone = 'muted',
}: { }: {
icon: React.ReactNode icon: React.ReactNode
eyebrow?: string
title: string title: string
description: string description: string
tone?: 'muted' | 'error' tone?: 'muted' | 'error'
}) { }) {
return ( return (
<div className={cn( <div className={cn(
'flex h-full flex-col items-center justify-center text-center', 'flex h-full flex-col items-center justify-center rounded-xl border border-dashed bg-[#0f1318] px-4 text-center',
tone === 'error' ? 'text-destructive' : 'text-muted-foreground' tone === 'error' ? 'border-destructive/30 text-destructive' : 'border-[#2a3038] text-[#9aa8ba]'
)}> )}>
<div className="mb-3 opacity-70" aria-hidden="true">{icon}</div> <div className="mb-3 opacity-70" aria-hidden="true">{icon}</div>
<p className="text-sm font-semibold">{title}</p> {eyebrow && <p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] opacity-80">{eyebrow}</p>}
<p className="text-sm font-semibold text-white">{title}</p>
<p className="mt-1 max-w-sm text-xs opacity-80">{description}</p> <p className="mt-1 max-w-sm text-xs opacity-80">{description}</p>
</div> </div>
) )

View File

@@ -378,76 +378,76 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
} }
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-background/90 backdrop-blur-md"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-[#070a0f]/85 p-4 backdrop-blur-sm">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[600px] bg-primary/10 rounded-full blur-[120px] pointer-events-none opacity-50" /> <div className="pointer-events-none absolute left-1/2 top-1/2 h-[460px] w-[680px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#ff7a1a]/10 blur-[110px]" />
<div className="w-full max-w-4xl glass-card border border-white/10 rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh] relative animate-in fade-in zoom-in duration-300"> <div className="relative flex max-h-[90vh] w-full max-w-4xl animate-in flex-col overflow-hidden rounded-2xl border border-[#2a3038] bg-[#15191e] shadow-2xl shadow-black/60 fade-in zoom-in duration-300">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-8 py-6 border-b border-white/5 bg-white/5 shrink-0"> <div className="sticky top-0 z-10 flex shrink-0 items-center justify-between border-b border-[#2a3038] bg-[#15191e]/95 px-6 py-4 backdrop-blur">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="p-3 rounded-2xl bg-primary/20 border border-primary/30"> <div className="rounded-xl border border-[#ff7a1a]/30 bg-[#ff7a1a]/15 p-2.5">
<Cpu className="h-6 w-6 text-primary glow-icon" /> <Cpu className="h-5 w-5 text-[#ff7a1a]" />
</div> </div>
<div> <div>
<h2 className="text-xl font-bold text-white tracking-tight">Configuración Técnica</h2> <h2 className="text-xl font-bold text-white tracking-tight">Configuración Técnica</h2>
<p className="text-xs text-primary font-medium flex items-center gap-1.5 mt-0.5"> <p className="mt-0.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#ff7a1a]">
<Database className="h-3 w-3" /> <Database className="h-3 w-3" />
POZO: <span className="text-white/80">{assetName}</span> POZO: <span className="text-[#d7dee8]">{assetName}</span>
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-xl text-white/40 hover:text-white hover:bg-white/10 transition-all border border-transparent hover:border-white/10" className="rounded-xl border border-[#2a3038] bg-[#0f1318] p-2 text-[#9aa8ba] transition-all hover:border-[#ff7a1a]/40 hover:bg-[#ff7a1a]/10 hover:text-white"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar space-y-10"> <div className="custom-scrollbar flex-1 space-y-6 overflow-y-auto bg-[#0f1318] p-6">
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<div className="h-12 w-12 rounded-full border-t-2 border-primary animate-spin" /> <div className="h-12 w-12 animate-spin rounded-full border-t-2 border-[#ff7a1a]" />
<p className="text-sm text-white/40 animate-pulse font-medium">Sincronizando configuración...</p> <p className="animate-pulse text-sm font-medium text-[#9aa8ba]">Sincronizando configuración...</p>
</div> </div>
) : ( ) : (
<> <>
{/* Integration Section */} {/* Integration Section */}
<section className="space-y-4"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-widest flex items-center gap-2"> <h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-[#9aa8ba]">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4 text-[#7c8da3]" />
Integración API Externa (Multi-tenant Cloud Ingest) Integración API Externa (Multi-tenant Cloud Ingest)
</h3> </h3>
<Badge variant="outline" className={cn( <Badge variant="outline" className={cn(
"text-[10px] uppercase font-bold", "text-[10px] uppercase font-bold",
apiSettings.is_collector_enabled ? "text-success border-success/30 bg-success/5" : "text-white/20 border-white/10" apiSettings.is_collector_enabled ? "border-emerald-400/30 bg-emerald-400/10 text-emerald-300" : "border-[#2a3038] bg-[#0f1318] text-[#6f7b8c]"
)}> )}>
{apiSettings.is_collector_enabled ? 'Activado' : 'Desactivado'} {apiSettings.is_collector_enabled ? 'Activado' : 'Desactivado'}
</Badge> </Badge>
</div> </div>
<div className="p-6 rounded-2xl bg-white/[0.03] border border-white/10 space-y-6"> <div className="space-y-5 rounded-2xl border border-[#2a3038] bg-[#15191e] p-5">
<div className="flex items-center justify-between p-4 bg-black/20 rounded-xl border border-white/5"> <div className="flex items-center justify-between rounded-xl border border-[#2a3038] bg-[#0f1318] p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
"p-2 rounded-lg transition-colors", "p-2 rounded-lg transition-colors",
apiSettings.is_collector_enabled ? "bg-success/20 text-success" : "bg-white/5 text-white/20" apiSettings.is_collector_enabled ? "bg-emerald-400/15 text-emerald-300" : "bg-[#15191e] text-[#6f7b8c]"
)}> )}>
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
</div> </div>
<div> <div>
<p className="text-xs font-bold text-white">Recolección Automática de Telemetría</p> <p className="text-xs font-bold text-white">Recolección Automática de Telemetría</p>
<p className="text-[10px] text-white/40">Si se activa, el Collector buscará datos en la URL especificada cada 10s.</p> <p className="text-[10px] text-[#9aa8ba]">Si se activa, el Collector buscará datos en la URL especificada cada 10s.</p>
</div> </div>
</div> </div>
<button <button
onClick={() => setApiSettings({...apiSettings, is_collector_enabled: !apiSettings.is_collector_enabled})} onClick={() => setApiSettings({...apiSettings, is_collector_enabled: !apiSettings.is_collector_enabled})}
className={cn( className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ring-2 ring-offset-2 ring-offset-black ring-transparent", "relative inline-flex h-6 w-11 items-center rounded-full ring-2 ring-transparent ring-offset-2 ring-offset-[#0f1318] transition-colors focus:outline-none",
apiSettings.is_collector_enabled ? "bg-success" : "bg-white/10" apiSettings.is_collector_enabled ? "bg-emerald-400" : "bg-[#2a3038]"
)} )}
> >
<span className={cn( <span className={cn(
@@ -460,11 +460,11 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{apiSettings.is_collector_enabled && ( {apiSettings.is_collector_enabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in slide-in-from-top-2 duration-300"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-white/40 uppercase ml-1">URL de API Externa</label> <label className="ml-1 text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">URL de API Externa</label>
<div className="relative"> <div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-white/20" /> <Globe className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#6f7b8c]" />
<input <input
className="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none transition-all placeholder:text-white/10" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] py-2.5 pl-9 pr-4 text-sm text-white outline-none transition-all placeholder:text-[#4e5a68] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="https://api.scada-external.com/v1/tags" placeholder="https://api.scada-external.com/v1/tags"
value={apiSettings.external_api_url} value={apiSettings.external_api_url}
onChange={e => setApiSettings({...apiSettings, external_api_url: e.target.value})} onChange={e => setApiSettings({...apiSettings, external_api_url: e.target.value})}
@@ -472,12 +472,12 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-white/40 uppercase ml-1">X-API-KEY / Token de Acceso</label> <label className="ml-1 text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">X-API-KEY / Token de Acceso</label>
<div className="relative"> <div className="relative">
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-white/20" /> <ShieldCheck className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#6f7b8c]" />
<input <input
type="password" type="password"
className="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none transition-all" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] py-2.5 pl-9 pr-4 text-sm text-white outline-none transition-all focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="••••••••••••••••" placeholder="••••••••••••••••"
value={apiSettings.external_api_key} value={apiSettings.external_api_key}
onChange={e => setApiSettings({...apiSettings, external_api_key: e.target.value})} onChange={e => setApiSettings({...apiSettings, external_api_key: e.target.value})}
@@ -491,7 +491,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
<Button <Button
onClick={handleUpdateAssetIntegration} onClick={handleUpdateAssetIntegration}
disabled={savingAsset} disabled={savingAsset}
className="bg-white/5 hover:bg-white/10 text-white border border-white/10 h-9 px-6 gap-2 text-xs font-bold transition-all" className="h-9 gap-2 border border-[#2a3038] bg-[#0f1318] px-6 text-xs font-bold text-[#d7dee8] transition-all hover:border-[#ff7a1a]/40 hover:bg-[#ff7a1a]/10 hover:text-white"
> >
{savingAsset ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />} {savingAsset ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
Guardar Configuración de Ingesta Guardar Configuración de Ingesta
@@ -500,13 +500,13 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
</section> </section>
<hr className="border-white/5" /> <hr className="border-[#2a3038]" />
{/* Metrología y Calibración Section */} {/* Metrología y Calibración Section */}
<section className="space-y-4"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-widest flex items-center gap-2"> <h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-[#9aa8ba]">
<Calendar className="h-4 w-4 text-primary" /> <Calendar className="h-4 w-4 text-[#ff7a1a]" />
Metrología y Aseguramiento de Calidad (ANH) Metrología y Aseguramiento de Calidad (ANH)
</h3> </h3>
{asset?.next_calibration_date && ( {asset?.next_calibration_date && (
@@ -527,15 +527,15 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
)} )}
</div> </div>
<div className="p-6 rounded-2xl bg-white/[0.03] border border-white/10 space-y-6"> <div className="space-y-5 rounded-2xl border border-[#2a3038] bg-[#15191e] p-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-white/40 uppercase ml-1">Fecha de Última Calibración</label> <label className="ml-1 text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Fecha de Última Calibración</label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-white/20" /> <Calendar className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#6f7b8c]" />
<input <input
type="date" type="date"
className="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none transition-all [color-scheme:dark]" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] py-2.5 pl-9 pr-4 text-sm text-white outline-none transition-all focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40 [color-scheme:dark]"
value={calibrationSettings.last_calibration_date} value={calibrationSettings.last_calibration_date}
onChange={e => setCalibrationSettings({...calibrationSettings, last_calibration_date: e.target.value})} onChange={e => setCalibrationSettings({...calibrationSettings, last_calibration_date: e.target.value})}
/> />
@@ -543,12 +543,12 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-white/40 uppercase ml-1">Fecha de Próxima Calibración</label> <label className="ml-1 text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Fecha de Próxima Calibración</label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-white/20" /> <Calendar className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#6f7b8c]" />
<input <input
type="date" type="date"
className="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none transition-all [color-scheme:dark]" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] py-2.5 pl-9 pr-4 text-sm text-white outline-none transition-all focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40 [color-scheme:dark]"
value={calibrationSettings.next_calibration_date} value={calibrationSettings.next_calibration_date}
onChange={e => setCalibrationSettings({...calibrationSettings, next_calibration_date: e.target.value})} onChange={e => setCalibrationSettings({...calibrationSettings, next_calibration_date: e.target.value})}
/> />
@@ -556,12 +556,12 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-bold text-white/40 uppercase ml-1">URL del Certificado de Calibración</label> <label className="ml-1 text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">URL del Certificado de Calibración</label>
<div className="relative"> <div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-white/20" /> <Globe className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#6f7b8c]" />
<input <input
type="text" type="text"
className="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none transition-all placeholder:text-white/10" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] py-2.5 pl-9 pr-4 text-sm text-white outline-none transition-all placeholder:text-[#4e5a68] focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="https://s3.domain.com/certificados/..." placeholder="https://s3.domain.com/certificados/..."
value={calibrationSettings.calibration_certificate_url} value={calibrationSettings.calibration_certificate_url}
onChange={e => setCalibrationSettings({...calibrationSettings, calibration_certificate_url: e.target.value})} onChange={e => setCalibrationSettings({...calibrationSettings, calibration_certificate_url: e.target.value})}
@@ -574,7 +574,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
<Button <Button
onClick={handleUpdateAssetCalibration} onClick={handleUpdateAssetCalibration}
disabled={savingCalibration} disabled={savingCalibration}
className="bg-white/5 hover:bg-white/10 text-white border border-white/10 h-9 px-6 gap-2 text-xs font-bold transition-all" className="h-9 gap-2 border border-[#2a3038] bg-[#0f1318] px-6 text-xs font-bold text-[#d7dee8] transition-all hover:border-[#ff7a1a]/40 hover:bg-[#ff7a1a]/10 hover:text-white"
> >
{savingCalibration ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />} {savingCalibration ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
Guardar Metrología Guardar Metrología
@@ -583,20 +583,20 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
</section> </section>
<hr className="border-white/5" /> <hr className="border-[#2a3038]" />
{/* Manual Fields Section */} {/* Manual Fields Section */}
<section className="space-y-6"> <section className="space-y-4">
<div className="flex items-center justify-between mb-2"> <div className="mb-1 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-widest flex items-center gap-2"> <h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-[#9aa8ba]">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4 text-[#7c8da3]" />
Operativa Manual (Baja Frecuencia) Operativa Manual (Baja Frecuencia)
</h3> </h3>
{!showAddManualField && ( {!showAddManualField && (
<Button <Button
size="sm" size="sm"
onClick={() => setShowAddManualField(true)} onClick={() => setShowAddManualField(true)}
className="bg-primary/20 text-primary hover:bg-primary/30 border border-primary/40 h-8 gap-1.5 transition-all" className="h-8 gap-1.5 border border-[#ff7a1a]/40 bg-[#ff7a1a]/15 text-[#ff7a1a] transition-all hover:bg-[#ff7a1a]/25"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Agregar Variable Agregar Variable
@@ -605,19 +605,19 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
{showAddManualField && ( {showAddManualField && (
<div className="p-6 rounded-2xl bg-white/5 border border-primary/30 animate-in slide-in-from-top-4 duration-300"> <div className="animate-in rounded-2xl border border-[#ff7a1a]/25 bg-[#15191e] p-5 slide-in-from-top-4 duration-300">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Zap className="h-4 w-4 text-primary" /> <Zap className="h-4 w-4 text-[#ff7a1a]" />
<h4 className="text-sm font-bold text-white">Registrar Variable Manual</h4> <h4 className="text-sm font-bold text-white">Registrar Variable Manual</h4>
</div> </div>
<form onSubmit={handleAddManualField} className="grid grid-cols-2 gap-4"> <form onSubmit={handleAddManualField} className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Nombre (Ej: BSW)</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Nombre (Ej: BSW)</label>
<input required className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" value={newManualField.name} onChange={e => setNewManualField({...newManualField, name: e.target.value})} /> <input required className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40" value={newManualField.name} onChange={e => setNewManualField({...newManualField, name: e.target.value})} />
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Tipo de Dato</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Tipo de Dato</label>
<select className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white outline-none focus:ring-1 focus:ring-primary" value={newManualField.type} onChange={e => setNewManualField({...newManualField, type: e.target.value as 'number'|'text'})}> <select className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40" value={newManualField.type} onChange={e => setNewManualField({...newManualField, type: e.target.value as 'number'|'text'})}>
<option value="number">Número</option> <option value="number">Número</option>
<option value="text">Texto</option> <option value="text">Texto</option>
</select> </select>
@@ -625,30 +625,30 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{newManualField.type === 'number' && ( {newManualField.type === 'number' && (
<> <>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Valor Mínimo (Opcional)</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Valor Mínimo (Opcional)</label>
<input type="number" step="any" className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" value={newManualField.min ?? ''} onChange={e => setNewManualField({...newManualField, min: e.target.value ? Number(e.target.value) : undefined})} /> <input type="number" step="any" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40" value={newManualField.min ?? ''} onChange={e => setNewManualField({...newManualField, min: e.target.value ? Number(e.target.value) : undefined})} />
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Valor Máximo (Opcional)</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Valor Máximo (Opcional)</label>
<input type="number" step="any" className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" value={newManualField.max ?? ''} onChange={e => setNewManualField({...newManualField, max: e.target.value ? Number(e.target.value) : undefined})} /> <input type="number" step="any" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40" value={newManualField.max ?? ''} onChange={e => setNewManualField({...newManualField, max: e.target.value ? Number(e.target.value) : undefined})} />
</div> </div>
</> </>
)} )}
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="required-field" checked={newManualField.required} onChange={e => setNewManualField({...newManualField, required: e.target.checked})} className="rounded bg-black/40 border-white/10 text-primary focus:ring-primary" /> <input type="checkbox" id="required-field" checked={newManualField.required} onChange={e => setNewManualField({...newManualField, required: e.target.checked})} className="rounded border-[#2a3038] bg-[#0f1318] text-[#ff7a1a] focus:ring-[#ff7a1a]" />
<label htmlFor="required-field" className="text-[10px] font-bold text-white/40 uppercase">Campo Obligatorio</label> <label htmlFor="required-field" className="text-[10px] font-bold uppercase text-[#9aa8ba]">Campo Obligatorio</label>
</div> </div>
<div className="col-span-2 flex justify-end gap-3 mt-4"> <div className="col-span-2 flex justify-end gap-3 mt-4">
<Button variant="ghost" size="sm" type="button" onClick={() => setShowAddManualField(false)} className="text-white/60 hover:text-white">Cancelar</Button> <Button variant="ghost" size="sm" type="button" onClick={() => setShowAddManualField(false)} className="text-[#9aa8ba] hover:text-white">Cancelar</Button>
<Button size="sm" type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90 px-6 font-bold uppercase text-[10px] tracking-widest">Aceptar</Button> <Button size="sm" type="submit" className="bg-[#ff7a1a] px-6 text-[10px] font-bold uppercase tracking-widest text-black hover:bg-[#ff8f3d]">Aceptar</Button>
</div> </div>
</form> </form>
</div> </div>
)} )}
<div className="rounded-xl border border-white/5 overflow-hidden"> <div className="overflow-hidden rounded-2xl border border-[#2a3038] bg-[#15191e]">
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs">
<thead className="bg-white/5 text-white/40 font-bold uppercase text-[9px]"> <thead className="bg-[#0f1318] text-[9px] font-bold uppercase text-[#9aa8ba]">
<tr> <tr>
<th className="px-4 py-2.5">Nombre</th> <th className="px-4 py-2.5">Nombre</th>
<th className="px-4 py-2.5">Tipo</th> <th className="px-4 py-2.5">Tipo</th>
@@ -657,12 +657,12 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
<th className="px-4 py-2.5 text-right">Acciones</th> <th className="px-4 py-2.5 text-right">Acciones</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-[#2a3038]">
{manualFields.length === 0 ? ( {manualFields.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-white/20 italic">Sin variables manuales.</td></tr> <tr><td colSpan={5} className="px-4 py-8 text-center italic text-[#6f7b8c]">Sin variables manuales.</td></tr>
) : ( ) : (
manualFields.map(f => ( manualFields.map(f => (
<tr key={f.id} className="hover:bg-white/[0.02] transition-colors group"> <tr key={f.id} className="group transition-colors hover:bg-[#0f1318]">
<td className="px-4 py-3 font-semibold text-white/90">{f.name}</td> <td className="px-4 py-3 font-semibold text-white/90">{f.name}</td>
<td className="px-4 py-3 text-[10px] font-mono text-white/60">{f.type === 'number' ? 'Número' : 'Texto'}</td> <td className="px-4 py-3 text-[10px] font-mono text-white/60">{f.type === 'number' ? 'Número' : 'Texto'}</td>
<td className="px-4 py-3 text-[10px] text-white/60"> <td className="px-4 py-3 text-[10px] text-white/60">
@@ -681,12 +681,12 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
)} )}
</tbody> </tbody>
</table> </table>
<div className="p-3 bg-white/[0.02] border-t border-white/5 flex justify-end"> <div className="flex justify-end border-t border-[#2a3038] bg-[#0f1318] p-3">
<Button <Button
size="sm" size="sm"
onClick={handleSaveManualFields} onClick={handleSaveManualFields}
disabled={savingManualFields} disabled={savingManualFields}
className="bg-white/5 hover:bg-white/10 text-white border border-white/10 h-8 px-4 gap-2 text-[10px] font-bold transition-all" className="h-8 gap-2 border border-[#2a3038] bg-[#15191e] px-4 text-[10px] font-bold text-[#d7dee8] transition-all hover:border-[#ff7a1a]/40 hover:bg-[#ff7a1a]/10 hover:text-white"
> >
{savingManualFields ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3 w-3" />} {savingManualFields ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3 w-3" />}
Guardar Cambios Guardar Cambios
@@ -695,13 +695,13 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
</section> </section>
<hr className="border-white/5" /> <hr className="border-[#2a3038]" />
{/* Devices Section */} {/* Devices Section */}
<section className="space-y-6"> <section className="space-y-4">
<div className="flex items-center justify-between mb-2"> <div className="mb-1 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-widest flex items-center gap-2"> <h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.2em] text-[#9aa8ba]">
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4 text-[#7c8da3]" />
Arquitectura Local (PLCs & Gateways) Arquitectura Local (PLCs & Gateways)
</h3> </h3>
{!showAddDevice && ( {!showAddDevice && (
@@ -717,7 +717,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
}) })
setShowAddDevice(true) setShowAddDevice(true)
}} }}
className="bg-primary/20 text-primary hover:bg-primary/30 border border-primary/40 h-8 gap-1.5 transition-all" className="h-8 gap-1.5 border border-[#ff7a1a]/40 bg-[#ff7a1a]/15 text-[#ff7a1a] transition-all hover:bg-[#ff7a1a]/25"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Nuevo Dispositivo Nuevo Dispositivo
@@ -726,26 +726,26 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
{showAddDevice && ( {showAddDevice && (
<div className="p-6 rounded-2xl bg-white/5 border border-primary/30 animate-in slide-in-from-top-4 duration-300"> <div className="animate-in rounded-2xl border border-[#ff7a1a]/25 bg-[#15191e] p-5 slide-in-from-top-4 duration-300">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Zap className="h-4 w-4 text-primary" /> <Zap className="h-4 w-4 text-[#ff7a1a]" />
<h4 className="text-sm font-bold text-white">Registrar Nuevo Dispositivo Industrial</h4> <h4 className="text-sm font-bold text-white">Registrar Nuevo Dispositivo Industrial</h4>
</div> </div>
<form onSubmit={handleAddDevice} className="grid grid-cols-2 gap-4"> <form onSubmit={handleAddDevice} className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Nombre Identificador</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Nombre Identificador</label>
<input <input
required required
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none transition-all" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none transition-all focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="Ej: PLC-VANTAGE-01" placeholder="Ej: PLC-VANTAGE-01"
value={newDevice.name} value={newDevice.name}
onChange={e => setNewDevice({...newDevice, name: e.target.value})} onChange={e => setNewDevice({...newDevice, name: e.target.value})}
/> />
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Protocolo</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Protocolo</label>
<select <select
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white outline-none focus:ring-1 focus:ring-primary" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
value={newDevice.protocol} value={newDevice.protocol}
onChange={e => setNewDevice({...newDevice, protocol: e.target.value as DeviceProtocol})} onChange={e => setNewDevice({...newDevice, protocol: e.target.value as DeviceProtocol})}
> >
@@ -758,21 +758,21 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</select> </select>
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Dirección IP / Host</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Dirección IP / Host</label>
<input <input
required required
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="192.168.1.10" placeholder="192.168.1.10"
value={newDevice.host} value={newDevice.host}
onChange={e => setNewDevice({...newDevice, host: e.target.value})} onChange={e => setNewDevice({...newDevice, host: e.target.value})}
/> />
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Puerto</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Puerto</label>
<input <input
type="number" type="number"
required required
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
value={newDevice.port} value={newDevice.port}
onChange={e => setNewDevice({...newDevice, port: parseInt(e.target.value)})} onChange={e => setNewDevice({...newDevice, port: parseInt(e.target.value)})}
/> />
@@ -781,10 +781,10 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{/* Protocol Specific Config */} {/* Protocol Specific Config */}
{newDevice.protocol === 'MODBUS_TCP' && ( {newDevice.protocol === 'MODBUS_TCP' && (
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Unit ID (Slave ID)</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Unit ID (Slave ID)</label>
<input <input
type="number" type="number"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
value={(newDevice.protocol_config?.unit_id as number) || 1} value={(newDevice.protocol_config?.unit_id as number) || 1}
onChange={e => setNewDevice({ onChange={e => setNewDevice({
...newDevice, ...newDevice,
@@ -797,10 +797,10 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{newDevice.protocol === 'S7' && ( {newDevice.protocol === 'S7' && (
<> <>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Rack</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Rack</label>
<input <input
type="number" type="number"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
value={(newDevice.protocol_config?.rack as number) || 0} value={(newDevice.protocol_config?.rack as number) || 0}
onChange={e => setNewDevice({ onChange={e => setNewDevice({
...newDevice, ...newDevice,
@@ -809,10 +809,10 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
/> />
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Slot</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Slot</label>
<input <input
type="number" type="number"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
value={(newDevice.protocol_config?.slot as number) || 1} value={(newDevice.protocol_config?.slot as number) || 1}
onChange={e => setNewDevice({ onChange={e => setNewDevice({
...newDevice, ...newDevice,
@@ -826,9 +826,9 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{newDevice.protocol === 'MQTT_SPARKPLUG_B' && ( {newDevice.protocol === 'MQTT_SPARKPLUG_B' && (
<> <>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Group ID</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Group ID</label>
<input <input
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="Sparkplug Group" placeholder="Sparkplug Group"
value={(newDevice.protocol_config?.group_id as string) || ''} value={(newDevice.protocol_config?.group_id as string) || ''}
onChange={e => setNewDevice({ onChange={e => setNewDevice({
@@ -838,9 +838,9 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
/> />
</div> </div>
<div> <div>
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Edge Node ID</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Edge Node ID</label>
<input <input
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="Edge Node" placeholder="Edge Node"
value={(newDevice.protocol_config?.edge_node_id as string) || ''} value={(newDevice.protocol_config?.edge_node_id as string) || ''}
onChange={e => setNewDevice({ onChange={e => setNewDevice({
@@ -854,9 +854,9 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{newDevice.protocol === 'SQL_DB' && ( {newDevice.protocol === 'SQL_DB' && (
<div className="col-span-2"> <div className="col-span-2">
<label className="text-[10px] font-bold text-white/40 uppercase mb-1.5 block">Connection String (Cadena de Conexión)</label> <label className="mb-1.5 block text-[10px] font-bold uppercase tracking-wider text-[#9aa8ba]">Connection String (Cadena de Conexión)</label>
<input <input
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-xl border border-[#2a3038] bg-[#0f1318] px-4 py-2.5 text-sm text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="postgresql://user:pass@host/db" placeholder="postgresql://user:pass@host/db"
value={(newDevice.protocol_config?.connection_string as string) || ''} value={(newDevice.protocol_config?.connection_string as string) || ''}
onChange={e => setNewDevice({ onChange={e => setNewDevice({
@@ -868,10 +868,10 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
)} )}
<div className="col-span-2 flex justify-end gap-3 mt-4"> <div className="col-span-2 flex justify-end gap-3 mt-4">
<Button variant="ghost" size="sm" onClick={() => setShowAddDevice(false)} className="text-white/60 hover:text-white"> <Button variant="ghost" size="sm" onClick={() => setShowAddDevice(false)} className="text-[#9aa8ba] hover:text-white">
Cancelar Cancelar
</Button> </Button>
<Button size="sm" type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90 px-6 font-bold uppercase text-[10px] tracking-widest"> <Button size="sm" type="submit" className="bg-[#ff7a1a] px-6 text-[10px] font-bold uppercase tracking-widest text-black hover:bg-[#ff8f3d]">
Guardar Dispositivo Guardar Dispositivo
</Button> </Button>
</div> </div>
@@ -880,9 +880,9 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
)} )}
{devices.length === 0 && !showAddDevice ? ( {devices.length === 0 && !showAddDevice ? (
<div className="py-20 flex flex-col items-center justify-center border border-dashed border-white/10 rounded-3xl bg-white/[0.02]"> <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-[#2a3038] bg-[#15191e] py-14">
<Activity className="h-10 w-10 text-white/20 mb-4" /> <Activity className="mb-4 h-10 w-10 text-[#6f7b8c]" />
<p className="text-sm text-white/40 font-medium">No hay dispositivos configurados localmente.</p> <p className="text-sm font-medium text-[#9aa8ba]">No hay dispositivos configurados localmente.</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -892,33 +892,33 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
return ( return (
<div key={device.id} className={cn( <div key={device.id} className={cn(
"rounded-2xl border transition-all duration-300 overflow-hidden", "overflow-hidden rounded-2xl border transition-all duration-300",
isExpanded ? "bg-white/[0.05] border-primary/30 ring-1 ring-primary/20" : "bg-white/[0.02] border-white/10 hover:border-white/20" isExpanded ? "border-[#ff7a1a]/40 bg-[#15191e] ring-1 ring-[#ff7a1a]/20" : "border-[#2a3038] bg-[#15191e] hover:border-[#3a424d]"
)}> )}>
<div <div
className="flex items-center justify-between px-6 py-5 cursor-pointer select-none" className="flex cursor-pointer select-none items-center justify-between px-5 py-4"
onClick={() => toggleDevice(device.id)} onClick={() => toggleDevice(device.id)}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cn( <div className={cn(
"p-2 rounded-xl transition-colors", "rounded-xl p-2 transition-colors",
isExpanded ? "bg-primary/20 text-primary" : "bg-white/5 text-white/40" isExpanded ? "bg-[#ff7a1a]/15 text-[#ff7a1a]" : "bg-[#0f1318] text-[#9aa8ba]"
)}> )}>
{isExpanded ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />} {isExpanded ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-bold text-white uppercase tracking-tight">{device.name}</span> <span className="text-sm font-bold text-white uppercase tracking-tight">{device.name}</span>
<Badge className="bg-primary/10 text-primary border-primary/20 text-[9px] px-1.5 py-0"> <Badge className="border-[#ff7a1a]/25 bg-[#ff7a1a]/10 px-1.5 py-0 text-[9px] text-[#ff7a1a]">
{device.protocol} {device.protocol}
</Badge> </Badge>
</div> </div>
<p className="text-[10px] text-white/40 font-mono mt-0.5">{device.host}:{device.port}</p> <p className="mt-0.5 font-mono text-[10px] text-[#9aa8ba]">{device.host}:{device.port}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-right mr-4 hidden sm:block"> <div className="mr-4 hidden text-right sm:block">
<p className="text-[10px] font-bold text-white/40 uppercase">Variables</p> <p className="text-[10px] font-bold uppercase text-[#9aa8ba]">Variables</p>
<p className="text-xs font-bold text-white">{deviceVars.length}</p> <p className="text-xs font-bold text-white">{deviceVars.length}</p>
</div> </div>
<button <button
@@ -934,10 +934,10 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="px-6 pb-6 pt-2 border-t border-white/5 animate-in fade-in slide-in-from-top-2"> <div className="animate-in border-t border-[#2a3038] px-5 pb-5 pt-3 fade-in slide-in-from-top-2">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h4 className="text-[10px] font-bold text-white/40 uppercase tracking-widest flex items-center gap-2"> <h4 className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-[#9aa8ba]">
<Settings2 className="h-3 w-3" /> <Settings2 className="h-3 w-3 text-[#7c8da3]" />
Tags de Instrumentación Tags de Instrumentación
</h4> </h4>
<button <button
@@ -951,7 +951,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
}) })
setShowAddVar(device.id) setShowAddVar(device.id)
}} }}
className="text-[10px] font-bold text-primary hover:text-primary/80 uppercase tracking-widest flex items-center gap-1.5 transition-colors" className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-[#ff7a1a] transition-colors hover:text-[#ff8f3d]"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Agregar Tag Agregar Tag
@@ -959,20 +959,20 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
{showAddVar === device.id && ( {showAddVar === device.id && (
<div className="mb-6 p-5 rounded-xl bg-black/40 border border-primary/20 animate-in zoom-in-95 duration-200"> <div className="animate-in mb-5 rounded-xl border border-[#ff7a1a]/20 bg-[#0f1318] p-4 zoom-in-95 duration-200">
<form onSubmit={(e) => handleAddVariable(e, device.id)} className="grid grid-cols-6 gap-3"> <form onSubmit={(e) => handleAddVariable(e, device.id)} className="grid grid-cols-6 gap-3">
<div className="col-span-2"> <div className="col-span-2">
<label className="text-[9px] font-bold text-white/40 uppercase mb-1.5 block">Alias (Nombre)</label> <label className="mb-1.5 block text-[9px] font-bold uppercase text-[#9aa8ba]">Alias (Nombre)</label>
<input <input
required required
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-lg border border-[#2a3038] bg-[#15191e] px-3 py-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="Presion_Cabezal" placeholder="Presion_Cabezal"
value={newVar.alias} value={newVar.alias}
onChange={e => setNewVar({...newVar, alias: e.target.value})} onChange={e => setNewVar({...newVar, alias: e.target.value})}
/> />
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<label className="text-[9px] font-bold text-white/40 uppercase mb-1.5 block"> <label className="mb-1.5 block text-[9px] font-bold uppercase text-[#9aa8ba]">
{device.protocol === 'S7' {device.protocol === 'S7'
? 'S7 Address (DB.Type.Off)' ? 'S7 Address (DB.Type.Off)'
: device.protocol === 'MQTT_SPARKPLUG_B' : device.protocol === 'MQTT_SPARKPLUG_B'
@@ -983,7 +983,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</label> </label>
<input <input
required required
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-lg border border-[#2a3038] bg-[#15191e] px-3 py-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder={ placeholder={
device.protocol === 'S7' device.protocol === 'S7'
? 'DB1.DBW0' ? 'DB1.DBW0'
@@ -998,9 +998,9 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
/> />
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<label className="text-[9px] font-bold text-white/40 uppercase mb-1.5 block">Tipo</label> <label className="mb-1.5 block text-[9px] font-bold uppercase text-[#9aa8ba]">Tipo</label>
<select <select
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white outline-none focus:ring-1 focus:ring-primary" className="w-full rounded-lg border border-[#2a3038] bg-[#15191e] px-3 py-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
value={newVar.data_type} value={newVar.data_type}
onChange={e => setNewVar({...newVar, data_type: e.target.value as VariableDataType})} onChange={e => setNewVar({...newVar, data_type: e.target.value as VariableDataType})}
> >
@@ -1012,16 +1012,16 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</select> </select>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<label className="text-[9px] font-bold text-white/40 uppercase mb-1.5 block">Unidad</label> <label className="mb-1.5 block text-[9px] font-bold uppercase text-[#9aa8ba]">Unidad</label>
<input <input
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-primary outline-none" className="w-full rounded-lg border border-[#2a3038] bg-[#15191e] px-3 py-2 text-xs text-white outline-none focus:border-[#ff7a1a]/60 focus:ring-1 focus:ring-[#ff7a1a]/40"
placeholder="PSI" placeholder="PSI"
value={newVar.unit} value={newVar.unit}
onChange={e => setNewVar({...newVar, unit: e.target.value})} onChange={e => setNewVar({...newVar, unit: e.target.value})}
/> />
</div> </div>
<div className="col-span-1 flex items-end"> <div className="col-span-1 flex items-end">
<Button size="sm" type="submit" className="w-full h-8 bg-primary/20 text-primary border border-primary/40 text-[9px] font-bold uppercase"> <Button size="sm" type="submit" className="h-8 w-full border border-[#ff7a1a]/40 bg-[#ff7a1a]/15 text-[9px] font-bold uppercase text-[#ff7a1a] hover:bg-[#ff7a1a]/25">
Crear Crear
</Button> </Button>
</div> </div>
@@ -1029,9 +1029,9 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
)} )}
<div className="rounded-xl border border-white/5 overflow-hidden"> <div className="overflow-hidden rounded-xl border border-[#2a3038]">
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs">
<thead className="bg-white/5 text-white/40 font-bold uppercase text-[9px]"> <thead className="bg-[#0f1318] text-[9px] font-bold uppercase text-[#9aa8ba]">
<tr> <tr>
<th className="px-4 py-2.5">Tag Alias</th> <th className="px-4 py-2.5">Tag Alias</th>
<th className="px-4 py-2.5">Dirección</th> <th className="px-4 py-2.5">Dirección</th>
@@ -1040,18 +1040,18 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
<th className="px-4 py-2.5 text-right">Acciones</th> <th className="px-4 py-2.5 text-right">Acciones</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-[#2a3038]">
{deviceVars.length === 0 ? ( {deviceVars.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-4 py-8 text-center text-white/20 italic"> <td colSpan={5} className="px-4 py-8 text-center italic text-[#6f7b8c]">
Sin variables configuradas. Sin variables configuradas.
</td> </td>
</tr> </tr>
) : ( ) : (
deviceVars.map((v) => ( deviceVars.map((v) => (
<tr key={v.id} className="hover:bg-white/[0.02] transition-colors group"> <tr key={v.id} className="group transition-colors hover:bg-[#0f1318]">
<td className="px-4 py-3 font-semibold text-white/90">{v.alias}</td> <td className="px-4 py-3 font-semibold text-white/90">{v.alias}</td>
<td className="px-4 py-3 font-mono text-primary/70">{v.address}</td> <td className="px-4 py-3 font-mono text-[#ff7a1a]/80">{v.address}</td>
<td className="px-4 py-3 text-[10px] font-mono text-white/60">{v.data_type}</td> <td className="px-4 py-3 text-[10px] font-mono text-white/60">{v.data_type}</td>
<td className="px-4 py-3 text-white/60 font-medium">{v.unit || '-'}</td> <td className="px-4 py-3 text-white/60 font-medium">{v.unit || '-'}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -1071,10 +1071,10 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{/* OPC UA Node Scanner - only visible for OPC_UA devices */} {/* OPC UA Node Scanner - only visible for OPC_UA devices */}
{device.protocol === 'OPC_UA' && ( {device.protocol === 'OPC_UA' && (
<div className="border-t border-white/5 pt-4 mt-4"> <div className="mt-4 border-t border-[#2a3038] pt-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h5 className="text-[10px] font-bold text-white/40 uppercase tracking-widest flex items-center gap-2"> <h5 className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-[#9aa8ba]">
<Search className="h-3.5 w-3.5" /> <Search className="h-3.5 w-3.5 text-[#7c8da3]" />
Escáner de Nodos OPC UA Escáner de Nodos OPC UA
</h5> </h5>
{!scanState.showScanner ? ( {!scanState.showScanner ? (
@@ -1091,7 +1091,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setScanState(prev => ({ ...prev, showScanner: false, result: null, error: null, scanning: false, selectedNodes: new Set() }))} onClick={() => setScanState(prev => ({ ...prev, showScanner: false, result: null, error: null, scanning: false, selectedNodes: new Set() }))}
className="text-white/40 hover:text-white h-7 text-[10px]" className="h-7 text-[10px] text-[#9aa8ba] hover:text-white"
> >
Cerrar Cerrar
</Button> </Button>
@@ -1102,7 +1102,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300"> <div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
className="flex-1 bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:ring-1 focus:ring-blue-500 outline-none placeholder:text-white/20" className="flex-1 rounded-xl border border-[#2a3038] bg-[#0f1318] px-3 py-2 text-xs text-white outline-none placeholder:text-[#4e5a68] focus:border-blue-400/60 focus:ring-1 focus:ring-blue-500/40"
placeholder="Nodo raíz (ej: ns=0;i=85)" placeholder="Nodo raíz (ej: ns=0;i=85)"
value={scanState.rootNode} value={scanState.rootNode}
onChange={e => setScanState(prev => ({ ...prev, rootNode: e.target.value }))} onChange={e => setScanState(prev => ({ ...prev, rootNode: e.target.value }))}
@@ -1138,7 +1138,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
{scanState.result && scanState.result.status === 'completed' && ( {scanState.result && scanState.result.status === 'completed' && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-[10px] text-white/40 font-bold uppercase"> <p className="text-[10px] font-bold uppercase text-[#9aa8ba]">
{scanState.result.nodes.length} nodos encontrados {scanState.result.nodes.length} nodos encontrados
{scanState.selectedNodes.size > 0 && ` · ${scanState.selectedNodes.size} seleccionados`} {scanState.selectedNodes.size > 0 && ` · ${scanState.selectedNodes.size} seleccionados`}
</p> </p>
@@ -1146,14 +1146,14 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
<Button <Button
size="sm" size="sm"
onClick={() => handleImportSelectedNodes(device.id)} onClick={() => handleImportSelectedNodes(device.id)}
className="bg-primary/20 text-primary hover:bg-primary/30 border border-primary/40 h-7 gap-1.5 text-[10px]" className="h-7 gap-1.5 border border-[#ff7a1a]/40 bg-[#ff7a1a]/15 text-[10px] text-[#ff7a1a] hover:bg-[#ff7a1a]/25"
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
Agregar {scanState.selectedNodes.size} seleccionado(s) Agregar {scanState.selectedNodes.size} seleccionado(s)
</Button> </Button>
)} )}
</div> </div>
<div className="max-h-64 overflow-y-auto rounded-xl border border-white/5 custom-scrollbar"> <div className="custom-scrollbar max-h-64 overflow-y-auto rounded-xl border border-[#2a3038]">
{scanState.result.nodes.length === 0 ? ( {scanState.result.nodes.length === 0 ? (
<p className="px-4 py-6 text-xs text-white/30 text-center italic">Sin nodos bajo este nodo raíz</p> <p className="px-4 py-6 text-xs text-white/30 text-center italic">Sin nodos bajo este nodo raíz</p>
) : ( ) : (
@@ -1162,16 +1162,16 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
key={node.node_id} key={node.node_id}
onClick={() => handleToggleScanNode(node.node_id)} onClick={() => handleToggleScanNode(node.node_id)}
className={cn( className={cn(
"flex items-center gap-3 px-4 py-2.5 cursor-pointer border-b border-white/5 last:border-0 transition-colors text-xs", "flex cursor-pointer items-center gap-3 border-b border-[#2a3038] px-4 py-2.5 text-xs transition-colors last:border-0",
scanState.selectedNodes.has(node.node_id) scanState.selectedNodes.has(node.node_id)
? "bg-primary/10 hover:bg-primary/15" ? "bg-[#ff7a1a]/10 hover:bg-[#ff7a1a]/15"
: "hover:bg-white/[0.03]" : "hover:bg-white/[0.03]"
)} )}
> >
<div className={cn( <div className={cn(
"h-4 w-4 rounded border flex-shrink-0 flex items-center justify-center", "h-4 w-4 rounded border flex-shrink-0 flex items-center justify-center",
scanState.selectedNodes.has(node.node_id) scanState.selectedNodes.has(node.node_id)
? "bg-primary border-primary" ? "border-[#ff7a1a] bg-[#ff7a1a]"
: "border-white/20" : "border-white/20"
)}> )}>
{scanState.selectedNodes.has(node.node_id) && ( {scanState.selectedNodes.has(node.node_id) && (
@@ -1192,7 +1192,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
"text-[8px] uppercase font-bold h-4 px-1.5", "text-[8px] uppercase font-bold h-4 px-1.5",
node.node_class === 'Variable' node.node_class === 'Variable'
? "text-green-400 border-green-400/30" ? "text-green-400 border-green-400/30"
: "text-white/20 border-white/10" : "border-[#2a3038] text-[#6f7b8c]"
)}> )}>
{node.node_class} {node.node_class}
</Badge> </Badge>
@@ -1226,7 +1226,7 @@ export default function DeviceConfigModal({ assetId, assetName, onClose }: Devic
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-8 py-4 border-t border-white/5 bg-white/5 flex items-center justify-between text-[10px] text-white/20 font-medium uppercase tracking-[0.2em]"> <div className="flex items-center justify-between border-t border-[#2a3038] bg-[#15191e] px-6 py-3 text-[10px] font-medium uppercase tracking-[0.2em] text-[#6f7b8c]">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-success animate-pulse" /> <div className="h-1.5 w-1.5 rounded-full bg-success animate-pulse" />

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeft, MapPin } from 'lucide-react' import { MapPin } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import type { EdgeProject } from '../api/projects-api' import type { EdgeProject } from '../api/projects-api'
@@ -16,34 +16,43 @@ function EdgeProjectHeader({ project }: EdgeProjectHeaderProps) {
const projectName = fallback(project?.name, project?.id ? `Proyecto ${project.id.slice(0, 8)}` : 'Proyecto Edge') const projectName = fallback(project?.name, project?.id ? `Proyecto ${project.id.slice(0, 8)}` : 'Proyecto Edge')
return ( return (
<header className="flex min-w-0 flex-1 items-start gap-3" aria-labelledby="project-detail-title"> <header className="min-w-0 flex-1 space-y-5" aria-labelledby="project-detail-title">
<Link <Link
to="/app/admin/projects" to="/app/admin/projects"
aria-label="Volver a proyectos Edge" aria-label="Volver a proyectos Edge"
className="mt-1 shrink-0 rounded-lg p-1.5 text-muted-foreground hover:text-foreground" className="inline-flex w-fit items-center text-sm font-medium text-[#9aa8ba] transition-colors hover:text-[#ff7a1a]"
> >
<ArrowLeft className="h-4 w-4" /> Proyectos
</Link> </Link>
<div className="mt-1 rounded-lg bg-primary/10 p-2 text-primary">
<MapPin className="h-4 w-4" aria-hidden="true" /> <div className="flex items-start gap-4">
<div className="rounded-2xl border border-[#ff7a1a]/30 bg-[#ff7a1a]/10 p-3 text-[#ff7a1a] shadow-lg shadow-black/20">
<MapPin className="h-6 w-6" aria-hidden="true" />
</div> </div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="min-w-0 space-y-4">
<h1 id="project-detail-title" className="truncate text-lg font-semibold"> <div className="flex flex-wrap items-center gap-3">
<h1 id="project-detail-title" className="truncate text-3xl font-semibold tracking-tight text-white">
{projectName} {projectName}
</h1> </h1>
{project ? ( {project ? (
<Badge variant={project.is_active ? 'default' : 'secondary'}> <Badge
variant={project.is_active ? 'default' : 'secondary'}
className={project.is_active
? 'border-[#27d796]/30 bg-[#27d796]/15 text-[#27d796] shadow-none'
: 'border-[#9aa8ba]/30 bg-[#9aa8ba]/10 text-[#9aa8ba]'}
>
{project.is_active ? 'Proyecto activo' : 'Proyecto inactivo'} {project.is_active ? 'Proyecto activo' : 'Proyecto inactivo'}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-x-5 gap-y-2 text-sm text-[#9aa8ba]">
<span>Cliente: <strong className="font-medium text-foreground">{fallback(project?.client, 'Sin cliente')}</strong></span> <span>Cliente: <strong className="font-medium text-white">{fallback(project?.client, 'Sin cliente')}</strong></span>
<span>Operador: <strong className="font-medium text-foreground">{fallback(project?.operator_name, 'Sin operador')}</strong></span> <span>Operador: <strong className="font-medium text-white">{fallback(project?.operator_name, 'Sin operador')}</strong></span>
<span>Contrato: <strong className="font-medium text-foreground">{fallback(project?.contract_number, 'Sin contrato')}</strong></span> <span>Contrato: <strong className="font-medium text-white">{fallback(project?.contract_number, 'Sin contrato')}</strong></span>
</div>
{project?.description ? <p className="max-w-3xl text-sm leading-6 text-[#9aa8ba]">{project.description}</p> : null}
</div> </div>
{project?.description ? <p className="text-xs text-muted-foreground">{project.description}</p> : null}
</div> </div>
</header> </header>
) )

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { Cpu, Download, Loader2, Plus, RefreshCcw, Terminal, Trash2, Upload } from 'lucide-react' import { Cpu, Download, Loader2, Plus, RefreshCcw, Terminal, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -16,11 +17,17 @@ type ProjectActionToolbarProps = {
onRequestDelete: () => void onRequestDelete: () => void
} }
function ActionGroup({ title, children }: { title: string; children: React.ReactNode }) { function ActionGroup({
title,
children,
}: {
title: string
children: ReactNode
}) {
return ( return (
<div className="space-y-1"> <div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</p> <p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#9aa8ba]">{title}</p>
<div className="flex flex-col gap-2 sm:flex-row">{children}</div> <div className="flex flex-wrap gap-2">{children}</div>
</div> </div>
) )
} }
@@ -37,52 +44,65 @@ function ProjectActionToolbar({
onCreateWell, onCreateWell,
onRequestDelete, onRequestDelete,
}: ProjectActionToolbarProps) { }: ProjectActionToolbarProps) {
const isInstallerReady = project?.installer_status === 'READY' const installerStatus = project?.installer_status
const isInstallerBuilding = project?.installer_status === 'BUILDING' || project?.installer_status === 'NOT_STARTED' const isInstallerReady = installerStatus === 'READY'
const windowsInstallerLabel = isInstallerReady
? 'Windows MSI'
: installerStatus === 'BUILDING'
? 'Windows en preparación'
: installerStatus === 'FAILED'
? 'Instalador con error'
: 'Build pendiente'
const sharedButtonClass = 'h-9 border-[#2a3038] bg-[#101419] px-3 text-xs font-semibold text-white hover:border-[#ff7a1a]/50 hover:bg-[#ff7a1a]/10'
return ( return (
<nav className="flex w-full flex-col gap-3 md:w-auto xl:flex-row" aria-label="Acciones del proyecto Edge"> <nav
<ActionGroup title="Instaladores"> className="w-full xl:max-w-3xl"
<Button size="sm" variant="outline" disabled={!isInstallerReady} onClick={onDownloadInstaller} className="text-xs"> aria-label="Acciones del proyecto Edge"
>
<div className="flex flex-wrap items-start gap-x-7 gap-y-4">
<ActionGroup title="INSTALADORES">
<Button size="sm" variant="outline" disabled={!isInstallerReady} onClick={onDownloadInstaller} className={sharedButtonClass}>
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
{isInstallerReady ? 'Windows (.msi)' : isInstallerBuilding ? 'Generando instalador' : 'Instalador con error'} {windowsInstallerLabel}
</Button> </Button>
<Button size="sm" variant="outline" onClick={onGetLinuxInstall} className="text-xs"> <Button size="sm" variant="outline" onClick={onGetLinuxInstall} className={sharedButtonClass} aria-label="Linux SSH/script" title="Linux SSH/script">
<Terminal className="h-3.5 w-3.5" /> <Terminal className="h-3.5 w-3.5" />
Linux (SSH) Linux (SSH)
</Button> </Button>
</ActionGroup> </ActionGroup>
<ActionGroup title="Operaciones"> <ActionGroup title="OPERACIONES">
<Button size="sm" variant="outline" onClick={onRebuildInstaller} className="text-xs"> <Button size="sm" variant="outline" onClick={onRebuildInstaller} className={sharedButtonClass}>
<RefreshCcw className="h-3.5 w-3.5" /> <RefreshCcw className="h-3.5 w-3.5" />
Reconstruir Reconstruir
</Button> </Button>
<Button size="sm" variant="secondary" onClick={onDeploySystem} disabled={deploying} className="text-xs"> <Button size="sm" variant="outline" onClick={onDeploySystem} disabled={deploying} className={sharedButtonClass}>
{deploying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Cpu className="h-3.5 w-3.5" />} {deploying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Cpu className="h-3.5 w-3.5" />}
Desplegar {deploying ? 'Despliegue en curso' : 'Desplegar'}
</Button> </Button>
</ActionGroup> </ActionGroup>
<ActionGroup title="Pozos y datos"> <ActionGroup title="POZOS Y DATOS">
<Button size="sm" variant="outline" onClick={onImportCsv} className="text-xs"> <Button size="sm" variant="outline" onClick={onImportCsv} className={sharedButtonClass}>
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
Carga masiva Carga masiva
</Button> </Button>
<Button size="sm" onClick={onCreateWell} className="text-xs"> <Button size="sm" onClick={onCreateWell} className="h-9 bg-[#ff7a1a] px-4 text-xs font-semibold text-black shadow-lg shadow-[#ff7a1a]/20 hover:bg-[#ff8d3d]">
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Registrar pozo Registrar pozo
</Button> </Button>
</ActionGroup> </ActionGroup>
{project ? ( {project ? (
<ActionGroup title="Zona de peligro"> <ActionGroup title="ZONA DE PELIGRO">
<Button size="sm" variant="destructive" onClick={onRequestDelete} disabled={deleting} className="text-xs"> <Button size="sm" variant="destructive" onClick={onRequestDelete} disabled={deleting} className="h-9 bg-[#ff4655] px-4 text-xs font-semibold text-white hover:bg-[#ff5d6a]">
{deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />} {deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
Eliminar proyecto {deleting ? 'Eliminando proyecto' : 'Eliminar proyecto'}
</Button> </Button>
</ActionGroup> </ActionGroup>
) : null} ) : null}
</div>
</nav> </nav>
) )
} }

View File

@@ -0,0 +1,150 @@
import { Droplets, FileText, RadioTower } from 'lucide-react'
import AgentLogViewer from './AgentLogViewer'
import { AgentHealthSummary } from './AgentHealthSummary'
import { EdgeProjectHeader } from './EdgeProjectHeader'
import { ProjectActionToolbar } from './ProjectActionToolbar'
import { WellCard } from './WellCard'
import type { TelemetryRecord } from '../api/telemetry-api'
import type { EdgeAssetFullConfig, EdgeProject } from '../api/projects-api'
import type { EdgeAsset } from '../api/wells-api'
type ProjectDashboardLayoutProps = {
project: EdgeProject | null
projectId: string | undefined
assets: EdgeAsset[]
assetConfigs?: EdgeAssetFullConfig[]
latestTelemetryByAssetId?: Record<string, TelemetryRecord | undefined>
deploying: boolean
deleting: boolean
onDownloadInstaller: () => void
onGetLinuxInstall: () => void
onRebuildInstaller: () => void
onDeploySystem: () => void
onImportCsv: () => void
onCreateWell: () => void
onRequestDelete: () => void
onConfigureAsset: (asset: EdgeAsset) => void
onToggleAssetCollector: (asset: EdgeAsset) => void
togglingCollectorAssetId?: string | null
}
function ScadaSectionLabel({ eyebrow, title }: { eyebrow: string; title: string }) {
return (
<div className="mb-3 flex items-center gap-3">
<span className="h-px w-8 bg-primary/60" aria-hidden="true" />
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-primary/80">{eyebrow}</p>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
</div>
</div>
)
}
function ProjectDashboardLayout({
project,
projectId,
assets,
assetConfigs = [],
latestTelemetryByAssetId = {},
deploying,
deleting,
onDownloadInstaller,
onGetLinuxInstall,
onRebuildInstaller,
onDeploySystem,
onImportCsv,
onCreateWell,
onRequestDelete,
onConfigureAsset,
onToggleAssetCollector,
togglingCollectorAssetId = null,
}: ProjectDashboardLayoutProps) {
return (
<section className="flex min-h-0 flex-1 flex-col bg-[#090b0f]" aria-label="Panel operativo SCADA">
<div className="shrink-0 border-b border-[#2a3038] bg-[#0b0f14] p-4 shadow-sm md:px-6">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.28em] text-[#9aa8ba]">
Panel operativo SCADA
</p>
<ScadaSectionLabel eyebrow="SCADA / Proyecto" title="Cabecera y acciones" />
<div className="rounded-3xl border border-[#2a3038] bg-[#15191e] p-5 shadow-2xl shadow-black/30 md:p-6">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<EdgeProjectHeader project={project} />
<ProjectActionToolbar
project={project}
deploying={deploying}
deleting={deleting}
onDownloadInstaller={onDownloadInstaller}
onGetLinuxInstall={onGetLinuxInstall}
onRebuildInstaller={onRebuildInstaller}
onDeploySystem={onDeploySystem}
onImportCsv={onImportCsv}
onCreateWell={onCreateWell}
onRequestDelete={onRequestDelete}
/>
</div>
<AgentHealthSummary project={project} />
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 md:p-6">
<div className="grid gap-6">
<section className="rounded-2xl border border-[#2a3038] bg-[#0b0f14] p-4 shadow-sm" aria-label="Inventario de pozos">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<ScadaSectionLabel eyebrow="Activos monitoreados" title="Inventario de pozos" />
<div className="rounded-full border border-border/70 bg-background/80 px-3 py-1 text-xs font-medium text-muted-foreground">
Pozos configurados: <span className="text-foreground">{assets.length}</span>
</div>
</div>
{assets.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-primary/30 bg-primary/5 px-6 py-16 text-center">
<div className="mb-4 rounded-2xl border border-primary/20 bg-background/80 p-3 text-primary shadow-sm">
<Droplets className="h-10 w-10" aria-hidden="true" />
</div>
<p className="text-base font-semibold text-foreground">Todavía no hay pozos configurados</p>
<p className="mt-2 max-w-xl text-sm text-muted-foreground">
Registrá el primer pozo o cargá el inventario por CSV para comenzar el monitoreo operativo.
</p>
</div>
) : (
<div className="grid gap-3 xl:grid-cols-2">
{assets.map((asset) => (
<WellCard
key={asset.id}
asset={asset}
config={assetConfigs.find((item) => item.asset.id === asset.id)}
latestTelemetry={latestTelemetryByAssetId[asset.id]}
onConfigure={onConfigureAsset}
onToggleCollector={onToggleAssetCollector}
isTogglingCollector={togglingCollectorAssetId === asset.id}
/>
))}
</div>
)}
</section>
<div className="rounded-2xl border border-border/70 bg-card/80 p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.24em] text-primary/80">
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
Registro de eventos
</div>
<div className="rounded-xl border border-border/70 bg-background/70 p-1">
{projectId ? (
<AgentLogViewer projectId={projectId} />
) : (
<div className="flex items-center gap-3 rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
<RadioTower className="h-4 w-4" aria-hidden="true" />
Los logs del agente se habilitan cuando el proyecto tiene un identificador válido.
</div>
)}
</div>
</div>
</div>
</div>
</section>
)
}
export { ProjectDashboardLayout }
export type { ProjectDashboardLayoutProps }

View File

@@ -7,10 +7,18 @@ import { AgentHealthSummary } from './AgentHealthSummary'
import { DeleteProjectConfirmDialog } from './DeleteProjectConfirmDialog' import { DeleteProjectConfirmDialog } from './DeleteProjectConfirmDialog'
import { EdgeProjectHeader } from './EdgeProjectHeader' import { EdgeProjectHeader } from './EdgeProjectHeader'
import { ProjectActionToolbar } from './ProjectActionToolbar' import { ProjectActionToolbar } from './ProjectActionToolbar'
import { ProjectDashboardLayout } from './ProjectDashboardLayout'
import { WellCard } from './WellCard' import { WellCard } from './WellCard'
import type { EdgeProject } from '../api/projects-api' import type { EdgeAssetFullConfig, EdgeProject } from '../api/projects-api'
import type { TelemetryRecord } from '../api/telemetry-api'
import type { EdgeAsset } from '../api/wells-api' import type { EdgeAsset } from '../api/wells-api'
vi.mock('./AgentLogViewer', () => ({
default: ({ projectId }: { projectId: string }) => (
<section aria-label="Logs del agente mock">Logs del agente para {projectId}</section>
),
}))
function render(ui: React.ReactNode) { function render(ui: React.ReactNode) {
const container = document.createElement('div') const container = document.createElement('div')
document.body.appendChild(container) document.body.appendChild(container)
@@ -54,6 +62,97 @@ const baseAsset: EdgeAsset = {
updated_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z',
} }
const baseAssetConfig: EdgeAssetFullConfig = {
asset: baseAsset,
devices: [{
device: {
id: 'device-1',
asset_id: baseAsset.id,
name: 'PLC pozo uno',
protocol: 'MODBUS_TCP',
host: '192.168.10.20',
port: 502,
protocol_config: {},
is_enabled: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
variables: [
{
id: 'var-1',
device_id: 'device-1',
address: '40001',
data_type: 'float32',
alias: 'PRESION_CABEZA',
unit: 'psi',
polling_interval_ms: 1000,
alarm_enabled: true,
byte_order: 'ABCD',
metadata: {},
is_enabled: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
{
id: 'var-2',
device_id: 'device-1',
address: '40003',
data_type: 'float32',
alias: 'TEMPERATURA_MOTOR',
unit: '°C',
polling_interval_ms: 1000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: {},
is_enabled: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
{
id: 'var-3',
device_id: 'device-1',
address: '40005',
data_type: 'float32',
alias: 'CAUDAL',
unit: 'bpd',
polling_interval_ms: 1000,
alarm_enabled: false,
byte_order: 'ABCD',
metadata: {},
is_enabled: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
},
],
}],
}
const recentTelemetry: TelemetryRecord = {
asset_id: baseAsset.id,
time: '2026-06-21T12:00:10Z',
data: { variables: { PRESION_CABEZA: { value: 540 } } },
}
function createLayoutProps(overrides: Partial<React.ComponentProps<typeof ProjectDashboardLayout>> = {}) {
return {
project,
projectId: project.id,
assets: [baseAsset],
deploying: false,
deleting: false,
onDownloadInstaller: vi.fn(),
onGetLinuxInstall: vi.fn(),
onRebuildInstaller: vi.fn(),
onDeploySystem: vi.fn(),
onImportCsv: vi.fn(),
onCreateWell: vi.fn(),
onRequestDelete: vi.fn(),
onConfigureAsset: vi.fn(),
onToggleAssetCollector: vi.fn(),
...overrides,
}
}
describe('project detail extracted components', () => { describe('project detail extracted components', () => {
let roots: ReturnType<typeof createRoot>[] = [] let roots: ReturnType<typeof createRoot>[] = []
@@ -80,6 +179,7 @@ describe('project detail extracted components', () => {
it('shows project identity and graceful context before inventory', () => { it('shows project identity and graceful context before inventory', () => {
const container = track(render(<EdgeProjectHeader project={project} />)) const container = track(render(<EdgeProjectHeader project={project} />))
expect(container.textContent).toContain('← Proyectos')
expect(container.textContent).toContain('Campo norte') expect(container.textContent).toContain('Campo norte')
expect(container.textContent).toContain('Cliente demo') expect(container.textContent).toContain('Cliente demo')
expect(container.textContent).toContain('Operadora sur') expect(container.textContent).toContain('Operadora sur')
@@ -93,11 +193,11 @@ describe('project detail extracted components', () => {
id: 'project-without-metadata', id: 'project-without-metadata',
name: '', name: '',
client: '', client: '',
operator_name: undefined, operator_name: '',
contract_number: null, contract_number: '',
description: '', description: '',
is_active: false, is_active: false,
} as EdgeProject }
const container = track(render(<EdgeProjectHeader project={sparseProject} />)) const container = track(render(<EdgeProjectHeader project={sparseProject} />))
@@ -118,7 +218,11 @@ describe('project detail extracted components', () => {
const inactiveWell = track(render(<WellCard asset={inactiveAsset} onConfigure={vi.fn()} />)) const inactiveWell = track(render(<WellCard asset={inactiveAsset} onConfigure={vi.fn()} />))
const activeWell = track(render(<WellCard asset={activeAsset} onConfigure={vi.fn()} />)) const activeWell = track(render(<WellCard asset={activeAsset} onConfigure={vi.fn()} />))
expect(health.textContent).toContain('Estado del agente')
expect(health.textContent).toContain('Enlace edge')
expect(health.textContent).toContain('Agente: edge-01')
expect(health.textContent).toContain('Agente online') expect(health.textContent).toContain('Agente online')
expect(health.textContent).toContain('Último reporte:')
expect(inactiveWell.textContent).toContain('Pozo inactivo') expect(inactiveWell.textContent).toContain('Pozo inactivo')
expect(activeWell.textContent).toContain('Pozo activo') expect(activeWell.textContent).toContain('Pozo activo')
expect(inactiveWell.textContent).not.toContain('Agente online') expect(inactiveWell.textContent).not.toContain('Agente online')
@@ -150,6 +254,7 @@ describe('project detail extracted components', () => {
)) ))
expect(noReport.textContent).toContain('Agente sin identificar') expect(noReport.textContent).toContain('Agente sin identificar')
expect(noReport.textContent).toContain('Enlace edge')
expect(noReport.textContent).toContain('Agente sin reporte') expect(noReport.textContent).toContain('Agente sin reporte')
expect(noReport.textContent).toContain('Último reporte: Sin reporte reciente') expect(noReport.textContent).toContain('Último reporte: Sin reporte reciente')
expect(staleReport.textContent).toContain('Agente sin identificar') expect(staleReport.textContent).toContain('Agente sin identificar')
@@ -174,33 +279,247 @@ describe('project detail extracted components', () => {
/> />
)) ))
expect(container.textContent).toContain('Instaladores') expect(container.textContent).toContain('INSTALADORES')
expect(container.textContent).toContain('Operaciones') expect(container.textContent).toContain('OPERACIONES')
expect(container.textContent).toContain('Pozos y datos') expect(container.textContent).toContain('POZOS Y DATOS')
expect(container.textContent).toContain('Zona de peligro') expect(container.textContent).toContain('ZONA DE PELIGRO')
expect(container.querySelectorAll('[data-variant="default"]')).toHaveLength(1) expect(container.querySelectorAll('[data-variant="default"]')).toHaveLength(1)
expect(container.querySelector('[data-variant="default"]')?.textContent).toContain('Registrar pozo') expect(container.querySelector('[data-variant="default"]')?.textContent).toContain('Registrar pozo')
expect(container.querySelector('[data-variant="destructive"]')?.textContent).toContain('Eliminar proyecto') expect(container.querySelector('[data-variant="destructive"]')?.textContent).toContain('Eliminar proyecto')
}) })
it('renders useful well metadata without unsupported controls', () => { it('preserves installer and pending action disabled states', () => {
const onConfigure = vi.fn() const callbacks = {
const container = track(render(<WellCard asset={baseAsset} onConfigure={onConfigure} />)) onDownloadInstaller: vi.fn(),
onGetLinuxInstall: vi.fn(),
onRebuildInstaller: vi.fn(),
onDeploySystem: vi.fn(),
onImportCsv: vi.fn(),
onCreateWell: vi.fn(),
onRequestDelete: vi.fn(),
}
const container = track(render(
<ProjectActionToolbar
project={{ ...project, installer_status: 'BUILDING' }}
deploying
deleting
{...callbacks}
/>
))
const findButton = (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find((item) =>
item.textContent?.includes(label)
)
expect(button, `${label} button`).toBeTruthy()
return button as HTMLButtonElement
}
expect(findButton('Windows en preparación').disabled).toBe(true)
expect(findButton('Despliegue en curso').disabled).toBe(true)
expect(findButton('Eliminando proyecto').disabled).toBe(true)
expect(findButton('Linux (SSH)').disabled).toBe(false)
expect(findButton('Reconstruir').disabled).toBe(false)
act(() => findButton('Windows en preparación').click())
act(() => findButton('Despliegue en curso').click())
act(() => findButton('Eliminando proyecto').click())
expect(callbacks.onDownloadInstaller).not.toHaveBeenCalled()
expect(callbacks.onDeploySystem).not.toHaveBeenCalled()
expect(callbacks.onRequestDelete).not.toHaveBeenCalled()
})
it('uses non-download labels for pending and failed Windows installers', () => {
const callbacks = {
onDownloadInstaller: vi.fn(),
onGetLinuxInstall: vi.fn(),
onRebuildInstaller: vi.fn(),
onDeploySystem: vi.fn(),
onImportCsv: vi.fn(),
onCreateWell: vi.fn(),
onRequestDelete: vi.fn(),
}
const pending = track(render(
<ProjectActionToolbar
project={{ ...project, installer_status: 'NOT_STARTED' }}
deploying={false}
deleting={false}
{...callbacks}
/>
))
const pendingButton = Array.from(pending.querySelectorAll('button')).find((item) =>
item.textContent?.includes('Build pendiente')
) as HTMLButtonElement
expect(pendingButton.disabled).toBe(true)
const failed = track(render(
<ProjectActionToolbar
project={{ ...project, installer_status: 'FAILED' }}
deploying={false}
deleting={false}
{...callbacks}
/>
))
const failedButton = Array.from(failed.querySelectorAll('button')).find((item) =>
item.textContent?.includes('Instalador con error')
) as HTMLButtonElement
expect(failedButton.disabled).toBe(true)
expect(failed.textContent).toContain('Reconstruir')
})
it('renders honest well connection details from real asset fields', () => {
const onConfigure = vi.fn()
const apiAsset = {
...baseAsset,
external_api_url: 'https://collector.example.test/api/wells/WELL-01',
}
const container = track(render(
<WellCard
asset={apiAsset}
config={{ ...baseAssetConfig, asset: apiAsset }}
onConfigure={onConfigure}
onToggleCollector={vi.fn()}
/>
))
expect(container.textContent).toContain('POZO · MODBUS_TCP')
expect(container.textContent).toContain('Pozo uno') expect(container.textContent).toContain('Pozo uno')
expect(container.textContent).toContain('WELL-01') expect(container.textContent).toContain('Configurado')
expect(container.textContent).toContain('Colector habilitado') expect(container.textContent).toContain('Endpoint')
expect(container.textContent).toContain('Próxima calibración') expect(container.textContent).toContain('https://collector.example.test/api/wells/WELL-01')
expect(container.textContent).toContain('Metadatos') expect(container.textContent).toContain('Variables')
expect(container.textContent).toContain('field: Norte') expect(container.textContent).toContain('2/3 activas')
expect(container.textContent).not.toContain('Control') expect(container.textContent).not.toContain('Conectado')
const configureButton = Array.from(container.querySelectorAll('button')).find((button) => const configureButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Configurar') button.textContent?.includes('Configurar variables')
) )
configureButton?.click() configureButton?.click()
expect(onConfigure).toHaveBeenCalledWith(baseAsset) const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Deshabilitar colector')
)
expect(configureButton).toBeTruthy()
expect(collectorButton).toBeTruthy()
expect(collectorButton?.disabled).toBe(false)
expect(collectorButton?.getAttribute('aria-label')).toContain('Deshabilitar colector')
expect(onConfigure).toHaveBeenCalledWith(apiAsset)
})
it('enables a configured disabled collector and forwards the real asset action', () => {
const onToggleCollector = vi.fn()
const disabledCollectorAsset = {
...baseAsset,
is_collector_enabled: false,
}
const container = track(render(
<WellCard
asset={disabledCollectorAsset}
config={{ ...baseAssetConfig, asset: disabledCollectorAsset }}
onConfigure={vi.fn()}
onToggleCollector={onToggleCollector}
/>
))
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Habilitar colector')
) as HTMLButtonElement
expect(collectorButton).toBeTruthy()
expect(collectorButton.disabled).toBe(false)
act(() => collectorButton.click())
expect(onToggleCollector).toHaveBeenCalledWith(disabledCollectorAsset)
})
it('blocks collector enablement when only inactive variables are configured', () => {
const onToggleCollector = vi.fn()
const disabledCollectorAsset = {
...baseAsset,
is_collector_enabled: false,
}
const inactiveVariableConfig: EdgeAssetFullConfig = {
...baseAssetConfig,
asset: disabledCollectorAsset,
devices: baseAssetConfig.devices.map((item) => ({
...item,
variables: item.variables.map((variable) => ({ ...variable, is_enabled: false })),
})),
}
const container = track(render(
<WellCard
asset={disabledCollectorAsset}
config={inactiveVariableConfig}
onConfigure={vi.fn()}
onToggleCollector={onToggleCollector}
/>
))
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Habilitar colector')
) as HTMLButtonElement
expect(container.textContent).toContain('0/3 activas')
expect(collectorButton.disabled).toBe(true)
act(() => collectorButton.click())
expect(onToggleCollector).not.toHaveBeenCalled()
})
it('renders device endpoint and active variable count from full config', () => {
const container = track(render(<WellCard asset={baseAsset} config={baseAssetConfig} onConfigure={vi.fn()} />))
expect(container.textContent).toContain('MODBUS_TCP 192.168.10.20:502')
expect(container.textContent).toContain('2/3 activas')
expect(container.textContent).toContain('Configurado')
expect(container.textContent).not.toContain('Sin endpoint configurado')
expect(container.textContent).not.toContain('Sin conteo disponible')
expect(container.textContent).not.toContain('Conectado')
})
it('renders connected only with recent telemetry from the asset', () => {
const container = track(render(
<WellCard asset={baseAsset} config={baseAssetConfig} latestTelemetry={recentTelemetry} onConfigure={vi.fn()} />
))
expect(container.textContent).toContain('Conectado')
expect(container.textContent).toContain('2/3 activas')
})
it('renders stale telemetry as offline instead of connected', () => {
const container = track(render(
<WellCard
asset={baseAsset}
config={baseAssetConfig}
latestTelemetry={{ ...recentTelemetry, time: '2026-06-21T11:50:00Z' }}
onConfigure={vi.fn()}
/>
))
expect(container.textContent).toContain('Sin telemetría')
expect(container.textContent).not.toContain('Conectado')
})
it('renders disabled collector and missing endpoint without inventing connection health', () => {
const disabledCollectorAsset = {
...baseAsset,
id: 'asset-disabled-collector',
external_api_url: '',
is_collector_enabled: false,
}
const container = track(render(<WellCard asset={disabledCollectorAsset} onConfigure={vi.fn()} onToggleCollector={vi.fn()} />))
expect(container.textContent).toContain('Colector deshabilitado')
expect(container.textContent).toContain('Sin endpoint configurado')
expect(container.textContent).toContain('Sin conteo disponible')
expect(container.textContent).not.toContain('Conectado')
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Habilitar colector')
) as HTMLButtonElement
expect(collectorButton.disabled).toBe(true)
}) })
it('renders sparse well metadata with stable fallback text', () => { it('renders sparse well metadata with stable fallback text', () => {
@@ -209,23 +528,22 @@ describe('project detail extracted components', () => {
id: 'asset-sparse-123456789', id: 'asset-sparse-123456789',
code: '', code: '',
name: '', name: '',
asset_type: '', asset_type: 'OTRO' as const,
metadata: { empty: '', missing: null }, metadata: { empty: '', missing: null },
is_collector_enabled: false, is_collector_enabled: false,
last_calibration_date: undefined, last_calibration_date: undefined,
next_calibration_date: undefined, next_calibration_date: undefined,
updated_at: undefined, updated_at: '',
} as EdgeAsset }
const container = track(render(<WellCard asset={sparseAsset} onConfigure={vi.fn()} />)) const container = track(render(<WellCard asset={sparseAsset} onConfigure={vi.fn()} />))
expect(container.textContent).toContain('Pozo asset-sp') expect(container.textContent).toContain('Pozo asset-sp')
expect(container.textContent).toContain('Código: Identificador no disponible') expect(container.textContent).toContain('OTRO')
expect(container.textContent).toContain('Tipo: No especificado')
expect(container.textContent).toContain('Colector deshabilitado') expect(container.textContent).toContain('Colector deshabilitado')
expect(container.textContent).toContain('Sin endpoint configurado')
expect(container.textContent).not.toContain('Sin metadatos adicionales') expect(container.textContent).not.toContain('Sin metadatos adicionales')
expect(container.textContent).not.toContain('Metadatos') expect(container.textContent).not.toContain('Metadatos')
expect(container.textContent).toContain('No registrada')
expect(container.textContent).not.toContain('undefined') expect(container.textContent).not.toContain('undefined')
expect(container.textContent).not.toContain('null') expect(container.textContent).not.toContain('null')
}) })
@@ -264,4 +582,82 @@ describe('project detail extracted components', () => {
}) })
expect(onConfirm).toHaveBeenCalledTimes(1) expect(onConfirm).toHaveBeenCalledTimes(1)
}) })
it('composes project dashboard sections with a well grid and mounted logs', () => {
const props = createLayoutProps({
assets: [
baseAsset,
{ ...baseAsset, id: 'asset-2', code: 'WELL-02', name: 'Pozo dos' },
],
assetConfigs: [baseAssetConfig],
})
const container = track(render(<ProjectDashboardLayout {...props} />))
expect(container.textContent).toContain('Panel operativo SCADA')
expect(container.textContent).toContain('Cabecera y acciones')
expect(container.textContent).toContain('Estado del agente')
expect(container.textContent).toContain('Inventario de pozos')
expect(container.textContent).toContain('Registro de eventos')
expect(container.textContent).toContain('Campo norte')
expect(container.textContent).toContain('INSTALADORES')
expect(container.textContent).toContain('Agente online')
expect(container.textContent).toContain('Pozo uno')
expect(container.textContent).toContain('Pozo dos')
expect(container.textContent).toContain('Logs del agente para project-1')
expect(container.textContent).not.toContain('Sin pozos registrados.')
})
it('shows the existing empty wells state while keeping logs mounted', () => {
const props = createLayoutProps({ assets: [] })
const container = track(render(<ProjectDashboardLayout {...props} />))
expect(container.textContent).toContain('Todavía no hay pozos configurados')
expect(container.textContent).toContain('Registrá el primer pozo o cargá el inventario por CSV para comenzar el monitoreo operativo.')
expect(container.textContent).toContain('Logs del agente para project-1')
expect(container.textContent).not.toContain('Pozo uno')
})
it('does not mount project logs without a project id', () => {
const props = createLayoutProps({ projectId: undefined })
const container = track(render(<ProjectDashboardLayout {...props} />))
expect(container.textContent).toContain('Campo norte')
expect(container.textContent).not.toContain('Logs del agente para')
})
it('forwards dashboard action callbacks and well configuration to the page', () => {
const props = createLayoutProps()
const container = track(render(<ProjectDashboardLayout {...props} />))
const clickButton = (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find((item) =>
item.textContent?.includes(label)
)
expect(button, `${label} button`).toBeTruthy()
act(() => button?.click())
}
clickButton('Windows MSI')
clickButton('Linux (SSH)')
clickButton('Reconstruir')
clickButton('Desplegar')
clickButton('Carga masiva')
clickButton('Registrar pozo')
clickButton('Eliminar proyecto')
clickButton('Configurar variables')
clickButton('Deshabilitar colector')
expect(props.onDownloadInstaller).toHaveBeenCalledTimes(1)
expect(props.onGetLinuxInstall).toHaveBeenCalledTimes(1)
expect(props.onRebuildInstaller).toHaveBeenCalledTimes(1)
expect(props.onDeploySystem).toHaveBeenCalledTimes(1)
expect(props.onImportCsv).toHaveBeenCalledTimes(1)
expect(props.onCreateWell).toHaveBeenCalledTimes(1)
expect(props.onRequestDelete).toHaveBeenCalledTimes(1)
expect(props.onConfigureAsset).toHaveBeenCalledWith(baseAsset)
expect(props.onToggleAssetCollector).toHaveBeenCalledWith(baseAsset)
})
}) })

View File

@@ -1,92 +1,152 @@
import { Settings, MapPin } from 'lucide-react' import { Cable, Loader2, Settings, MapPin } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import type { TelemetryRecord } from '../api/telemetry-api'
import type { EdgeAssetFullConfig } from '../api/projects-api'
import type { EdgeAsset } from '../api/wells-api' import type { EdgeAsset } from '../api/wells-api'
type WellCardProps = { type WellCardProps = {
asset: EdgeAsset asset: EdgeAsset
config?: EdgeAssetFullConfig
latestTelemetry?: TelemetryRecord
onConfigure: (asset: EdgeAsset) => void onConfigure: (asset: EdgeAsset) => void
onToggleCollector?: (asset: EdgeAsset) => void
isTogglingCollector?: boolean
} }
function formatDate(value?: string) { type WellStatus = {
if (!value) return 'No registrada' label: string
const date = new Date(value) className: string
if (Number.isNaN(date.getTime())) return 'No registrada'
return date.toLocaleDateString('es-CO')
} }
function getWellDisplay(asset: EdgeAsset) { const statusClasses = {
connected: 'border-[#27d796]/35 bg-[#27d796]/15 text-[#27d796]',
configured: 'border-[#38bdf8]/35 bg-[#38bdf8]/15 text-[#7dd3fc]',
incomplete: 'border-[#f59e0b]/35 bg-[#f59e0b]/15 text-[#fbbf24]',
offline: 'border-[#ff4655]/35 bg-[#ff4655]/15 text-[#ff4655]',
disabled: 'border-[#9aa8ba]/30 bg-[#9aa8ba]/10 text-[#9aa8ba]',
}
function getTelemetryAgeSeconds(latestTelemetry?: TelemetryRecord, now = Date.now()) {
if (!latestTelemetry?.time) return null
const timestamp = new Date(latestTelemetry.time).getTime()
if (Number.isNaN(timestamp)) return null
return (now - timestamp) / 1000
}
function getWellDisplay(asset: EdgeAsset, config?: EdgeAssetFullConfig, latestTelemetry?: TelemetryRecord, now = Date.now()) {
const title = asset.name?.trim() || asset.code?.trim() || `Pozo ${asset.id.slice(0, 8)}` const title = asset.name?.trim() || asset.code?.trim() || `Pozo ${asset.id.slice(0, 8)}`
const subtitle = asset.code?.trim() || asset.asset_type || 'Identificador no disponible' const firstDevice = config?.devices.find(({ device }) => device.is_enabled) ?? config?.devices[0]
const endpoint = asset.external_api_url?.trim()
|| (firstDevice ? `${firstDevice.device.protocol} ${firstDevice.device.host}:${firstDevice.device.port}` : '')
const totalVariables = config?.devices.reduce((count, item) => count + item.variables.length, 0)
const activeVariables = config?.devices.reduce(
(count, item) => count + item.variables.filter((variable) => variable.is_enabled && item.device.is_enabled).length,
0
)
const hasEndpoint = endpoint.length > 0
const hasActiveVariables = typeof activeVariables === 'number' && activeVariables > 0
const telemetryAgeSeconds = getTelemetryAgeSeconds(latestTelemetry, now)
const status: WellStatus = !asset.is_active || !asset.is_collector_enabled
? { label: asset.is_active ? 'Colector deshabilitado' : 'Inactivo', className: statusClasses.disabled }
: telemetryAgeSeconds !== null && telemetryAgeSeconds < 60
? { label: 'Conectado', className: statusClasses.connected }
: telemetryAgeSeconds !== null && telemetryAgeSeconds >= 300
? { label: 'Sin telemetría', className: statusClasses.offline }
: hasEndpoint && hasActiveVariables
? { label: 'Configurado', className: statusClasses.configured }
: { label: 'Configuración incompleta', className: statusClasses.incomplete }
const protocol = firstDevice?.device.protocol
const subtitle = [asset.asset_type || 'Activo', protocol].filter(Boolean).join(' · ')
return { return {
title, title,
subtitle, subtitle,
statusLabel: asset.is_active ? 'Pozo activo' : 'Pozo inactivo', endpoint: endpoint || 'Sin endpoint configurado',
status,
activeLabel: asset.is_active ? 'Pozo activo' : 'Pozo inactivo',
collectorLabel: asset.is_collector_enabled ? 'Colector habilitado' : 'Colector deshabilitado', collectorLabel: asset.is_collector_enabled ? 'Colector habilitado' : 'Colector deshabilitado',
variablesLabel: typeof totalVariables === 'number' ? `${activeVariables}/${totalVariables} activas` : 'Sin conteo disponible',
canEnableCollector: asset.is_active && hasEndpoint && hasActiveVariables,
} }
} }
function WellCard({ asset, onConfigure }: WellCardProps) { function DetailRow({ label, value }: { label: string; value: string }) {
const display = getWellDisplay(asset) return (
<div className="flex items-start justify-between gap-4 border-b border-[#2a3038]/80 py-2.5 last:border-b-0">
<dt className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8a99]">{label}</dt>
<dd className="max-w-[70%] break-words text-right font-mono text-xs font-medium text-[#e6edf6]">{value}</dd>
</div>
)
}
function WellCard({ asset, config, latestTelemetry, onConfigure, onToggleCollector, isTogglingCollector = false }: WellCardProps) {
const display = getWellDisplay(asset, config, latestTelemetry)
const metadataEntries = Object.entries(asset.metadata ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '') const metadataEntries = Object.entries(asset.metadata ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '')
const canToggleCollector = Boolean(onToggleCollector) && (asset.is_collector_enabled || display.canEnableCollector)
const collectorActionLabel = asset.is_collector_enabled ? 'Deshabilitar colector' : 'Habilitar colector'
const collectorActionTitle = canToggleCollector
? `${collectorActionLabel} y desplegar configuración al agente edge`
: 'Configurá un endpoint y variables antes de habilitar el colector'
return ( return (
<Card className="glass-card hover:border-primary/40"> <Card className="overflow-hidden border-[#232a33] bg-[#11161c] shadow-xl shadow-black/25 transition-colors hover:border-[#f97316]/45">
<CardContent className="flex h-full flex-col gap-4 p-5"> <CardContent className="flex h-full flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 gap-3"> <div className="flex min-w-0 gap-3">
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary"> <div className="mt-0.5 rounded-xl border border-[#f97316]/25 bg-[#f97316]/10 p-2 text-[#f97316] shadow-sm">
<MapPin className="h-4 w-4" aria-hidden="true" /> <MapPin className="h-4 w-4" aria-hidden="true" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-base font-semibold">{display.title}</h2> <h2 className="truncate text-base font-semibold leading-tight text-[#f7fafc]">{display.title}</h2>
<p className="truncate text-xs text-muted-foreground">Código: {display.subtitle}</p> <p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">{display.subtitle}</p>
<p className="text-xs text-muted-foreground">Tipo: {asset.asset_type || 'No especificado'}</p>
</div> </div>
</div> </div>
<Badge variant={asset.is_active ? 'default' : 'secondary'} className="shrink-0"> <Badge variant="outline" className={`shrink-0 ${display.status.className}`}>
{display.statusLabel} {display.status.label}
</Badge> </Badge>
</div> </div>
<dl className="grid grid-cols-1 gap-2 text-xs text-muted-foreground min-[420px]:grid-cols-2"> <section aria-label="Datos de conexión del pozo" className="rounded-xl border border-[#2a3038] bg-[#0b0f14]/70 px-3">
<div className="rounded-lg border border-border/60 bg-background/40 p-2"> <dl>
<dt>Colector</dt> <DetailRow label="Endpoint" value={display.endpoint} />
<dd className="mt-1 font-medium text-foreground">{display.collectorLabel}</dd> <DetailRow label="Variables" value={display.variablesLabel} />
</div> <DetailRow label="Colector" value={display.collectorLabel} />
<div className="rounded-lg border border-border/60 bg-background/40 p-2"> <DetailRow label="Estado" value={display.activeLabel} />
<dt>Última calibración</dt>
<dd className="mt-1 font-medium text-foreground">{formatDate(asset.last_calibration_date)}</dd>
</div>
<div className="rounded-lg border border-border/60 bg-background/40 p-2">
<dt>Próxima calibración</dt>
<dd className="mt-1 font-medium text-foreground">{formatDate(asset.next_calibration_date)}</dd>
</div>
<div className="rounded-lg border border-border/60 bg-background/40 p-2">
<dt>Actualizado</dt>
<dd className="mt-1 font-medium text-foreground">{formatDate(asset.updated_at)}</dd>
</div>
</dl> </dl>
</section>
{metadataEntries.length > 0 && ( {metadataEntries.length > 0 && (
<div className="rounded-lg bg-muted/40 p-2 text-xs text-muted-foreground"> <div className="rounded-xl border border-[#2a3038] bg-[#0f1318] p-3 text-xs text-[#9aa8ba]">
<p className="mb-2 font-medium text-foreground">Metadatos</p> <p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#7d8a99]">Metadatos</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{metadataEntries.slice(0, 3).map(([key, value]) => ( {metadataEntries.slice(0, 3).map(([key, value]) => (
<span key={key} className="rounded-md bg-background/70 px-2 py-1"> <span key={key} className="max-w-full truncate rounded-md border border-[#2a3038] bg-[#15191e] px-2 py-1">
<span className="text-muted-foreground">{key}</span>: <span className="font-medium text-foreground">{String(value)}</span> <span className="text-[#7d8a99]">{key}</span>: <span className="font-medium text-[#e6edf6]">{String(value)}</span>
</span> </span>
))} ))}
</div> </div>
</div> </div>
)} )}
<div className="mt-auto"> <div className="mt-auto flex flex-col gap-2 sm:flex-row">
<Button size="sm" variant="secondary" onClick={() => onConfigure(asset)} className="w-full text-xs"> <Button size="sm" variant="secondary" onClick={() => onConfigure(asset)} className="flex-1 text-xs">
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
Configurar Configurar variables
</Button>
<Button
size="sm"
variant="outline"
disabled={!canToggleCollector || isTogglingCollector}
title={collectorActionTitle}
aria-label={`${collectorActionLabel}: ${collectorActionTitle}`}
onClick={() => onToggleCollector?.(asset)}
className="flex-1 border-[#2a3038] bg-transparent text-xs text-[#d7e0ea] hover:border-[#38bdf8]/45 hover:bg-[#38bdf8]/10 disabled:text-[#7d8a99]"
>
{isTogglingCollector ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Cable className="h-3.5 w-3.5" />}
{isTogglingCollector ? 'Aplicando cambio' : collectorActionLabel}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@@ -0,0 +1,132 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CreateProjectPage from './CreateProjectPage'
import { createProject, triggerBuild } from '../api/projects-api'
const navigateMock = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
vi.mock('../api/projects-api', () => ({
createProject: vi.fn(),
triggerBuild: vi.fn(),
}))
async function setInputValue(element: Element | null, value: string) {
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
throw new Error('Expected form field to exist')
}
const valueSetter = Object.getOwnPropertyDescriptor(
element instanceof HTMLInputElement ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype,
'value'
)?.set
await act(async () => {
valueSetter?.call(element, value)
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
})
}
async function flushPromises() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('CreateProjectPage', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
let queryClient: QueryClient
let invalidateQueries: ReturnType<typeof vi.fn>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
invalidateQueries = vi.fn()
queryClient.invalidateQueries = invalidateQueries
vi.mocked(createProject).mockResolvedValue({
id: 'project-t10',
name: 'T10',
client: 'Cliente T10',
operator_name: 'OPERADOR_T10',
contract_number: 'CONT-T10',
description: 'Proyecto T10',
is_active: true,
created_at: '2026-06-24T00:00:00Z',
})
vi.mocked(triggerBuild).mockResolvedValue(undefined)
})
afterEach(async () => {
await act(async () => {
root.unmount()
})
container.remove()
vi.clearAllMocks()
})
async function renderPage() {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateProjectPage />
</MemoryRouter>
</QueryClientProvider>
)
})
}
it('renders the create form with the v0 carbon surface and visible projects back link', async () => {
await renderPage()
expect(container.textContent).toContain('Nuevo proyecto Edge')
expect(container.textContent).toContain('Datos operativos del proyecto')
expect(container.querySelector('a[href="/app/admin/projects"]')?.textContent).toContain('Proyectos')
expect(container.innerHTML).toContain('bg-[#090b0f]')
expect(container.innerHTML).toContain('bg-[#15191e]')
expect(container.innerHTML).toContain('border-[#2a3038]')
})
it('invalidates sidebar caches immediately after creating a project', async () => {
await renderPage()
await setInputValue(container.querySelector('input[placeholder="Ej. Operación Llanos"]'), 'T10')
await setInputValue(container.querySelector('input[placeholder="Ej. Ecopetrol S.A."]'), 'Cliente T10')
await setInputValue(container.querySelector('input[placeholder="Ej. ECOPETROL_S_A"]'), 'OPERADOR_T10')
await setInputValue(container.querySelector('input[placeholder="Ej. CONT-2026-XYZ"]'), 'CONT-T10')
await setInputValue(container.querySelector('textarea'), 'Proyecto T10')
await act(async () => {
container.querySelector('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
})
await flushPromises()
expect(createProject).toHaveBeenCalledWith({
name: 'T10',
client: 'Cliente T10',
operator_name: 'OPERADOR_T10',
contract_number: 'CONT-T10',
description: 'Proyecto T10',
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin-sidebar-projects'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin-sidebar-project-wells'] })
expect(triggerBuild).toHaveBeenCalledWith('project-t10')
expect(navigateMock).toHaveBeenCalledWith('/app/admin/projects/project-t10')
})
})

View File

@@ -1,11 +1,13 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { createProject, triggerBuild } from '../api/projects-api' import { createProject, triggerBuild } from '../api/projects-api'
import { Loader2, ArrowLeft, Send, Package } from 'lucide-react' import { Loader2, ArrowLeft, Send, RadioTower, FolderKanban } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
export default function CreateProjectPage() { export default function CreateProjectPage() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -23,6 +25,8 @@ export default function CreateProjectPage() {
setError(null) setError(null)
try { try {
const resp = await createProject(formData) const resp = await createProject(formData)
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-projects'] })
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-project-wells'] })
await triggerBuild(resp.id) await triggerBuild(resp.id)
navigate(`/app/admin/projects/${resp.id}`) navigate(`/app/admin/projects/${resp.id}`)
} catch (err: any) { } catch (err: any) {
@@ -33,93 +37,113 @@ export default function CreateProjectPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col bg-[#090b0f] text-foreground">
{/* Header */} <div className="shrink-0 border-b border-[#2a3038] bg-[#090b0f]/95 px-4 py-5 sm:px-6">
<div className="flex items-center gap-3 px-6 h-14 border-b border-border bg-card shrink-0">
<Link <Link
to="/app/admin/projects" to="/app/admin/projects"
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors" className="inline-flex items-center gap-2 rounded-md border border-[#2a3038] bg-[#15191e] px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#9aa8ba] transition-colors hover:border-[#ff7a1a]/50 hover:text-[#ff7a1a] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff7a1a]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#090b0f]"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="size-4" />
Proyectos
</Link> </Link>
<Package className="h-4 w-4 text-primary" />
<h1 className="text-base font-semibold">Nuevo Proyecto</h1> <div className="mt-5 flex items-start gap-3">
<p className="text-xs text-muted-foreground ml-1 hidden sm:block"> <span className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-[#ff7a1a]/20 bg-[#ff7a1a]/10 text-[#ff7a1a] shadow-sm shadow-orange-500/10">
Configura un nuevo entorno de monitoreo edge. <RadioTower className="size-5" />
</span>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-[#ff7a1a]">Consola SCADA</p>
<h1 className="mt-1 text-2xl font-bold tracking-tight text-foreground">Nuevo proyecto Edge</h1>
<p className="mt-1 max-w-2xl text-sm text-[#9aa8ba]">
Configura un nuevo entorno de monitoreo, despliegue y operación en campo.
</p> </p>
</div> </div>
</div>
</div>
{/* Content */} <div className="flex-1 overflow-y-auto p-4 sm:p-6">
<div className="flex-1 overflow-y-auto p-6"> <div className="mx-auto max-w-2xl overflow-hidden rounded-2xl border border-[#2a3038] bg-[#15191e] shadow-xl shadow-black/20">
<div className="max-w-lg mx-auto rounded-xl border border-border bg-card p-6"> <div className="border-b border-[#2a3038] bg-[#171b20] px-5 py-4 sm:px-6">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-xl border border-[#ff7a1a]/20 bg-[#ff7a1a]/10 text-[#ff7a1a]">
<FolderKanban className="size-5" />
</span>
<div>
<h2 className="text-base font-semibold text-foreground">Datos operativos del proyecto</h2>
<p className="mt-1 text-xs text-[#9aa8ba]">Estos datos alimentan la consola, el agente Edge y los reportes regulatorios.</p>
</div>
</div>
</div>
<div className="p-5 sm:p-6">
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-[#9aa8ba]">
Nombre del Proyecto Nombre del Proyecto
</label> </label>
<input <input
required required
placeholder="Ej. Operación Llanos" placeholder="Ej. Operación Llanos"
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] px-4 py-2.5 text-sm text-foreground placeholder:text-[#9aa8ba]/40 transition-all focus:border-[#ff7a1a]/60 focus:outline-none focus:ring-2 focus:ring-[#ff7a1a]/30"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-[#9aa8ba]">
Cliente / Razón Social Cliente / Razón Social
</label> </label>
<input <input
required required
placeholder="Ej. Ecopetrol S.A." placeholder="Ej. Ecopetrol S.A."
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] px-4 py-2.5 text-sm text-foreground placeholder:text-[#9aa8ba]/40 transition-all focus:border-[#ff7a1a]/60 focus:outline-none focus:ring-2 focus:ring-[#ff7a1a]/30"
value={formData.client} value={formData.client}
onChange={(e) => setFormData({ ...formData, client: e.target.value })} onChange={(e) => setFormData({ ...formData, client: e.target.value })}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-[#9aa8ba]">
Operador (Identificador ANH) Operador (Identificador ANH)
</label> </label>
<input <input
required required
placeholder="Ej. ECOPETROL_S_A" placeholder="Ej. ECOPETROL_S_A"
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] px-4 py-2.5 font-mono text-sm text-foreground placeholder:text-[#9aa8ba]/40 transition-all focus:border-[#ff7a1a]/60 focus:outline-none focus:ring-2 focus:ring-[#ff7a1a]/30"
value={formData.operator_name} value={formData.operator_name}
onChange={(e) => setFormData({ ...formData, operator_name: e.target.value })} onChange={(e) => setFormData({ ...formData, operator_name: e.target.value })}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-[#9aa8ba]">
Número de Contrato Número de Contrato
</label> </label>
<input <input
required required
placeholder="Ej. CONT-2026-XYZ" placeholder="Ej. CONT-2026-XYZ"
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all" className="w-full rounded-lg border border-[#2a3038] bg-[#090b0f] px-4 py-2.5 font-mono text-sm text-foreground placeholder:text-[#9aa8ba]/40 transition-all focus:border-[#ff7a1a]/60 focus:outline-none focus:ring-2 focus:ring-[#ff7a1a]/30"
value={formData.contract_number} value={formData.contract_number}
onChange={(e) => setFormData({ ...formData, contract_number: e.target.value })} onChange={(e) => setFormData({ ...formData, contract_number: e.target.value })}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-[#9aa8ba]">
Descripción <span className="normal-case font-normal">(Opcional)</span> Descripción <span className="normal-case font-normal">(Opcional)</span>
</label> </label>
<textarea <textarea
rows={3} rows={3}
placeholder="Detalles adicionales del proyecto..." placeholder="Detalles adicionales del proyecto..."
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all resize-none" className="w-full resize-none rounded-lg border border-[#2a3038] bg-[#090b0f] px-4 py-2.5 text-sm text-foreground placeholder:text-[#9aa8ba]/40 transition-all focus:border-[#ff7a1a]/60 focus:outline-none focus:ring-2 focus:ring-[#ff7a1a]/30"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/> />
</div> </div>
{error && ( {error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3 text-destructive text-sm"> <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error} {error}
</div> </div>
)} )}
@@ -127,7 +151,7 @@ export default function CreateProjectPage() {
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 gap-2" className="w-full gap-2 bg-[#ff7a1a] text-[#090b0f] shadow-sm shadow-orange-500/20 hover:bg-[#ff7a1a]/90 focus-visible:ring-[#ff7a1a]/60 focus-visible:ring-offset-[#090b0f]"
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Crear Proyecto y Configurar Agente Crear Proyecto y Configurar Agente
@@ -136,5 +160,6 @@ export default function CreateProjectPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@@ -0,0 +1,282 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ProjectDetailsPage from './ProjectDetailsPage'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { deploySystem, getProject, getProjectFullConfig, listDeployments } from '../api/projects-api'
import { getAssetTelemetry } from '../api/telemetry-api'
import { updateAsset } from '../api/wells-api'
import { toast } from 'sonner'
const layoutPropsSpy = vi.fn()
vi.mock('../components/ProjectDashboardLayout', () => ({
ProjectDashboardLayout: (props: any) => {
layoutPropsSpy(props)
const asset = props.assets?.[0] ?? {
id: 'asset-1',
project_id: props.projectId,
code: 'WELL-01',
name: 'Pozo uno',
asset_type: 'POZO',
is_active: true,
metadata: { field: 'Norte' },
external_api_url: 'http://127.0.0.1:5020',
is_collector_enabled: false,
created_at: '2026-06-20T00:00:00Z',
updated_at: '2026-06-20T00:00:00Z',
}
return (
<section aria-label="Detalle de proyecto mock">
Proyecto {props.projectId}
<span>Activos mock: {props.assets?.length ?? 0}</span>
<span>Configs mock: {props.assetConfigs?.length ?? 0}</span>
<span>Telemetry mock: {props.latestTelemetryByAssetId?.[asset.id]?.time ?? 'none'}</span>
<button
type="button"
onClick={() => props.onToggleAssetCollector(asset)}
>
Habilitar colector mock
</button>
</section>
)
},
}))
vi.mock('../components/DeviceConfigModal', () => ({
default: () => null,
}))
vi.mock('../components/DeleteProjectConfirmDialog', () => ({
DeleteProjectConfirmDialog: () => null,
}))
vi.mock('../api/projects-api', () => ({
getProject: vi.fn(),
getProjectFullConfig: vi.fn(),
deploySystem: vi.fn(),
listDeployments: vi.fn(),
rebuildInstaller: vi.fn(),
getLinuxInstallScript: vi.fn(),
getLinuxInstallToken: vi.fn(),
deleteProject: vi.fn(),
}))
vi.mock('../api/wells-api', () => ({
createAsset: vi.fn(),
updateAsset: vi.fn(),
}))
vi.mock('../api/telemetry-api', () => ({
getAssetTelemetry: vi.fn(),
}))
vi.mock('@/features/admin/telemetry/api/telemetry-ws-api', () => ({
createTelemetryWsTicket: vi.fn(() => new Promise(() => {})),
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
loading: vi.fn(),
success: vi.fn(),
},
}))
async function flushEffects() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('ProjectDetailsPage project context', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
let queryClient: QueryClient
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
vi.mocked(getProjectFullConfig).mockResolvedValue({
project: {
id: 'project-t10',
name: 'T10',
client: 'Cliente T10',
operator_name: 'Operadora T10',
contract_number: 'CTR-T10',
is_active: true,
created_at: '2026-06-20T00:00:00Z',
},
assets: [{
asset: {
id: 'asset-1',
project_id: 'project-t10',
code: 'WELL-01',
name: 'Pozo uno',
asset_type: 'POZO',
is_active: true,
metadata: { field: 'Norte' },
external_api_url: 'http://127.0.0.1:5020',
is_collector_enabled: false,
created_at: '2026-06-20T00:00:00Z',
updated_at: '2026-06-20T00:00:00Z',
},
devices: [],
}],
})
vi.mocked(getAssetTelemetry).mockResolvedValue([{ asset_id: 'asset-1', time: '2026-06-21T12:00:00Z', data: { value: 10 } }])
vi.mocked(listDeployments).mockResolvedValue([])
vi.mocked(updateAsset).mockResolvedValue({
id: 'asset-1',
project_id: 'project-t10',
code: 'WELL-01',
name: 'Pozo uno',
asset_type: 'POZO',
is_active: true,
metadata: { field: 'Norte' },
external_api_url: 'http://127.0.0.1:5020',
is_collector_enabled: true,
created_at: '2026-06-20T00:00:00Z',
updated_at: '2026-06-20T00:00:00Z',
})
vi.mocked(deploySystem).mockResolvedValue({ message: 'ok', flows_generated: true })
vi.mocked(getProject).mockResolvedValue({
id: 'project-t10',
name: 'T10',
client: 'Cliente T10',
operator_name: 'Operadora T10',
contract_number: 'CTR-T10',
description: 'Proyecto T10',
is_active: true,
installer_status: 'NOT_STARTED',
created_at: '2026-06-20T00:00:00Z',
})
useAuthStore.setState({
accessToken: 'token-1',
token: 'token-1',
activeProjectId: null,
projects: [
{ id: 'project-t10', name: 'T10' },
{ id: 'project-other', name: 'Otro' },
],
})
})
afterEach(async () => {
await act(async () => {
root.unmount()
})
queryClient.clear()
document.body.innerHTML = ''
vi.clearAllMocks()
useAuthStore.setState({
accessToken: null,
token: null,
activeProjectId: null,
projects: [],
})
})
it('syncs the URL project as active context for subsequent dashboard navigation', async () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/projects/project-t10']}>
<Routes>
<Route path="/app/admin/projects/:id" element={<ProjectDetailsPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushEffects()
expect(useAuthStore.getState().activeProjectId).toBe('project-t10')
expect(getProjectFullConfig).toHaveBeenCalledWith('project-t10')
expect(getAssetTelemetry).toHaveBeenCalledWith('asset-1', 1)
expect(container.textContent).toContain('Activos mock: 1')
expect(container.textContent).toContain('Configs mock: 1')
expect(container.textContent).toContain('Telemetry mock: 2026-06-21T12:00:00Z')
expect(getProject).toHaveBeenCalledWith('project-t10')
})
it('enables a well collector through the existing asset update and project deploy APIs', async () => {
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/projects/project-t10']}>
<Routes>
<Route path="/app/admin/projects/:id" element={<ProjectDetailsPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushEffects()
const button = Array.from(container.querySelectorAll('button')).find((item) =>
item.textContent?.includes('Habilitar colector mock')
)
expect(button).toBeTruthy()
await act(async () => {
button?.click()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(updateAsset).toHaveBeenCalledWith('asset-1', expect.objectContaining({
name: 'Pozo uno',
is_active: true,
is_collector_enabled: true,
external_api_url: 'http://127.0.0.1:5020',
}))
expect(deploySystem).toHaveBeenCalledWith('project-t10')
expect(getProjectFullConfig).toHaveBeenCalledTimes(2)
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin-sidebar-project-wells'] })
})
it('shows saved-but-not-deployed feedback and reloads after deploy failure', async () => {
vi.mocked(deploySystem).mockRejectedValueOnce(new Error('edge agent unreachable'))
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/app/admin/projects/project-t10']}>
<Routes>
<Route path="/app/admin/projects/:id" element={<ProjectDetailsPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
)
})
await flushEffects()
const button = Array.from(container.querySelectorAll('button')).find((item) =>
item.textContent?.includes('Habilitar colector mock')
)
await act(async () => {
button?.click()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(updateAsset).toHaveBeenCalledWith('asset-1', expect.objectContaining({ is_collector_enabled: true }))
expect(deploySystem).toHaveBeenCalledWith('project-t10')
expect(toast.error).toHaveBeenCalledWith('Cambio guardado, pero no desplegado', {
description: 'edge agent unreachable',
})
expect(getProjectFullConfig).toHaveBeenCalledTimes(2)
})
})

View File

@@ -2,27 +2,42 @@ import { useEffect, useState, useRef } from 'react'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { listAssets, createAsset, EdgeAsset } from '../api/wells-api' import { useQueryClient } from '@tanstack/react-query'
import { getProject, EdgeProject, deploySystem, listDeployments, EdgeDeployment, rebuildInstaller, getLinuxInstallScript, getLinuxInstallToken, deleteProject } from '../api/projects-api' import { createAsset, updateAsset, type EdgeAsset } from '../api/wells-api'
import { getAssetTelemetry, type TelemetryRecord } from '../api/telemetry-api'
import {
deleteProject,
deploySystem,
getLinuxInstallScript,
getLinuxInstallToken,
getProject,
getProjectFullConfig,
listDeployments,
rebuildInstaller,
type EdgeAssetFullConfig,
type EdgeDeployment,
type EdgeProject,
} from '../api/projects-api'
import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api' import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api'
import { import {
Loader2, Droplets, Download, CheckCircle2, AlertCircle, Terminal, Copy, CheckCheck, Upload Loader2, Download, CheckCircle2, AlertCircle, Terminal, Copy, CheckCheck, Upload
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import DeviceConfigModal from '../components/DeviceConfigModal' import DeviceConfigModal from '../components/DeviceConfigModal'
import AgentLogViewer from '../components/AgentLogViewer'
import { AgentHealthSummary } from '../components/AgentHealthSummary'
import { DeleteProjectConfirmDialog } from '../components/DeleteProjectConfirmDialog' import { DeleteProjectConfirmDialog } from '../components/DeleteProjectConfirmDialog'
import { EdgeProjectHeader } from '../components/EdgeProjectHeader' import { ProjectDashboardLayout } from '../components/ProjectDashboardLayout'
import { ProjectActionToolbar } from '../components/ProjectActionToolbar'
import { WellCard } from '../components/WellCard'
export default function ProjectDetailsPage() { export default function ProjectDetailsPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient()
const token = useAuthStore(s => s.token) const token = useAuthStore(s => s.token)
const activeProjectId = useAuthStore(s => s.activeProjectId)
const setActiveProject = useAuthStore(s => s.setActiveProject)
const [project, setProject] = useState<EdgeProject | null>(null) const [project, setProject] = useState<EdgeProject | null>(null)
const [assets, setAssets] = useState<EdgeAsset[]>([]) const [assets, setAssets] = useState<EdgeAsset[]>([])
const [assetConfigs, setAssetConfigs] = useState<EdgeAssetFullConfig[]>([])
const [latestTelemetryByAssetId, setLatestTelemetryByAssetId] = useState<Record<string, TelemetryRecord | undefined>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [newWell, setNewWell] = useState({ name: '', code: '' }) const [newWell, setNewWell] = useState({ name: '', code: '' })
@@ -36,6 +51,7 @@ export default function ProjectDetailsPage() {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [isDeletingProject, setIsDeletingProject] = useState(false) const [isDeletingProject, setIsDeletingProject] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [togglingCollectorAssetId, setTogglingCollectorAssetId] = useState<string | null>(null)
const [showImportModal, setShowImportModal] = useState(false) const [showImportModal, setShowImportModal] = useState(false)
const [importProtocol, setImportProtocol] = useState<'MODBUS_TCP' | 'S7' | 'OPC_UA' | 'MQTT_SPARKPLUG_B' | 'SQL_DB'>('MODBUS_TCP') const [importProtocol, setImportProtocol] = useState<'MODBUS_TCP' | 'S7' | 'OPC_UA' | 'MQTT_SPARKPLUG_B' | 'SQL_DB'>('MODBUS_TCP')
@@ -117,11 +133,29 @@ export default function ProjectDetailsPage() {
// Usamos un Ref para el estado previo para evitar stale closures en el polling // Usamos un Ref para el estado previo para evitar stale closures en el polling
const prevStatusRef = useRef<string | null>(null) const prevStatusRef = useRef<string | null>(null)
useEffect(() => {
if (id && activeProjectId !== id) {
setActiveProject(id)
}
}, [activeProjectId, id, setActiveProject])
const loadAssets = async () => { const loadAssets = async () => {
if (!id) return if (!id) return
try { try {
const data = await listAssets(id) const config = await getProjectFullConfig(id)
setAssets(data) setAssets(config.assets.map((item) => item.asset))
setAssetConfigs(config.assets)
const telemetryEntries = await Promise.all(
config.assets.map(async ({ asset }) => {
try {
const records = await getAssetTelemetry(asset.id, 1)
return [asset.id, records[0]] as const
} catch {
return [asset.id, undefined] as const
}
})
)
setLatestTelemetryByAssetId(Object.fromEntries(telemetryEntries))
} catch (err: any) { } catch (err: any) {
console.error(err.message) console.error(err.message)
} finally { } finally {
@@ -258,9 +292,8 @@ export default function ProjectDetailsPage() {
} }
}, [id]) }, [id])
const handleDeploySystem = async () => { const handleDownloadTemplate = async () => {
if (!id) return if (!id) return;
setDeploying(true)
try { try {
await deploySystem(id) await deploySystem(id)
toast.success("Sistema desplegado", { description: "Configuración flows.json enviada correctamente." }) toast.success("Sistema desplegado", { description: "Configuración flows.json enviada correctamente." })
@@ -271,11 +304,58 @@ export default function ProjectDetailsPage() {
} }
} }
const handleToggleAssetCollector = async (asset: EdgeAsset) => {
if (!id || togglingCollectorAssetId) return
const nextCollectorState = !asset.is_collector_enabled
setTogglingCollectorAssetId(asset.id)
let persistedCollectorState = false
try {
await updateAsset(asset.id, {
name: asset.name,
is_active: asset.is_active,
metadata: asset.metadata,
external_api_url: asset.external_api_url,
external_api_key: asset.external_api_key,
is_collector_enabled: nextCollectorState,
last_calibration_date: asset.last_calibration_date || null,
next_calibration_date: asset.next_calibration_date || null,
calibration_certificate_url: asset.calibration_certificate_url || null,
})
persistedCollectorState = true
await deploySystem(id)
toast.success(nextCollectorState ? 'Colector habilitado' : 'Colector deshabilitado', {
description: 'La configuración actualizada fue desplegada al agente edge.',
})
await loadAssets()
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-project-wells'] })
} catch (err: any) {
toast.error(
persistedCollectorState ? 'Cambio guardado, pero no desplegado' : 'Error al guardar cambio de colector',
{ description: err.message }
)
await loadAssets()
} finally {
setTogglingCollectorAssetId(null)
}
};
// Usamos un Ref para el estado previo para evitar stale closures en el polling
const prevStatusRef = useRef<string | null>(null)
const handleRebuildInstaller = async () => { const handleRebuildInstaller = async () => {
if (!id) return if (!id) return
try { try {
await rebuildInstaller(id) await rebuildInstaller(id)
toast.info("Solicitud enviada") toast.success("Reconstrucción solicitada", { description: "El instalador Windows se está preparando." })
setProject(prev => prev ? { ...prev, installer_status: 'BUILDING' } : prev)
setTimeout(async () => {
try {
const refreshedProject = await getProject(id)
setProject(refreshedProject)
} catch (err) {
console.error("Error refreshing project after rebuild:", err)
}
}, 1500)
} catch (err: any) { } catch (err: any) {
toast.error("Error de reconstrucción", { description: err.message }) toast.error("Error de reconstrucción", { description: err.message })
} }
@@ -333,6 +413,7 @@ export default function ProjectDetailsPage() {
setShowCreateModal(false) setShowCreateModal(false)
toast.success("Pozo registrado") toast.success("Pozo registrado")
loadAssets() loadAssets()
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-project-wells'] })
} catch (err: any) { } catch (err: any) {
toast.error("Error al crear pozo", { description: err.message }) toast.error("Error al crear pozo", { description: err.message })
} }
@@ -343,6 +424,8 @@ export default function ProjectDetailsPage() {
setIsDeletingProject(true) setIsDeletingProject(true)
try { try {
await deleteProject(project.id) await deleteProject(project.id)
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-projects'] })
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-project-wells'] })
toast.success('Proyecto Edge eliminado') toast.success('Proyecto Edge eliminado')
setShowDeleteDialog(false) setShowDeleteDialog(false)
navigate('/app/admin/projects') navigate('/app/admin/projects')
@@ -386,13 +469,17 @@ export default function ProjectDetailsPage() {
) )
} }
const isInstallerReady = project?.installer_status === 'READY'
const isInstallerBuilding = project?.installer_status === 'BUILDING' || project?.installer_status === 'NOT_STARTED'
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header Responsivo */} <ProjectDashboardLayout
<div className="flex flex-col gap-4 border-b border-border bg-card p-4 shrink-0 md:px-6 xl:flex-row xl:items-start">
<EdgeProjectHeader project={project} />
<ProjectActionToolbar
project={project} project={project}
projectId={id}
assets={assets}
assetConfigs={assetConfigs}
latestTelemetryByAssetId={latestTelemetryByAssetId}
deploying={deploying} deploying={deploying}
deleting={isDeletingProject} deleting={isDeletingProject}
onDownloadInstaller={handleDownloadInstaller} onDownloadInstaller={handleDownloadInstaller}
@@ -402,26 +489,10 @@ export default function ProjectDetailsPage() {
onImportCsv={() => setShowImportModal(true)} onImportCsv={() => setShowImportModal(true)}
onCreateWell={() => setShowCreateModal(true)} onCreateWell={() => setShowCreateModal(true)}
onRequestDelete={() => setShowDeleteDialog(true)} onRequestDelete={() => setShowDeleteDialog(true)}
onConfigureAsset={setSelectedAsset}
onToggleAssetCollector={handleToggleAssetCollector}
togglingCollectorAssetId={togglingCollectorAssetId}
/> />
</div>
<AgentHealthSummary project={project} />
<div className="flex-1 overflow-y-auto p-6">
{assets.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 border border-dashed rounded-xl grayscale opacity-50">
<Droplets className="h-10 w-10 mb-3" />
<p className="text-sm">Sin pozos registrados.</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => (
<WellCard key={asset.id} asset={asset} onConfigure={setSelectedAsset} />
))}
</div>
)}
{id && <AgentLogViewer projectId={id} />}
</div>
{showCreateModal && ( {showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">

View File

@@ -4,11 +4,16 @@ import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ProjectListPage from './ProjectListPage' import ProjectListPage from './ProjectListPage'
import { listProjects } from '../api/projects-api' import { listProjects } from '../api/projects-api'
import { listAssets } from '../api/wells-api'
vi.mock('../api/projects-api', () => ({ vi.mock('../api/projects-api', () => ({
listProjects: vi.fn(), listProjects: vi.fn(),
})) }))
vi.mock('../api/wells-api', () => ({
listAssets: vi.fn(),
}))
async function flushEffects() { async function flushEffects() {
await act(async () => { await act(async () => {
await Promise.resolve() await Promise.resolve()
@@ -16,6 +21,11 @@ async function flushEffects() {
}) })
} }
function metricValue(container: HTMLElement, label: string) {
const labelNode = Array.from(container.querySelectorAll('p')).find((node) => node.textContent === label)
return labelNode?.nextElementSibling?.textContent
}
describe('ProjectListPage status badges', () => { describe('ProjectListPage status badges', () => {
let container: HTMLDivElement let container: HTMLDivElement
let root: ReturnType<typeof createRoot> let root: ReturnType<typeof createRoot>
@@ -23,6 +33,7 @@ describe('ProjectListPage status badges', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-20T18:00:00Z')) vi.setSystemTime(new Date('2026-06-20T18:00:00Z'))
vi.mocked(listAssets).mockResolvedValue([])
container = document.createElement('div') container = document.createElement('div')
document.body.appendChild(container) document.body.appendChild(container)
root = createRoot(container) root = createRoot(container)
@@ -72,9 +83,125 @@ describe('ProjectListPage status badges', () => {
}) })
await flushEffects() await flushEffects()
expect(container.textContent).toContain('Proyecto activo') expect(container.textContent).toContain('Activo')
expect(container.textContent).toContain('Agente online') expect(container.textContent).toContain('Agente online')
expect(container.textContent).toContain('Agente offline') expect(container.textContent).toContain('Agente offline')
expect(container.textContent).not.toContain('Offline') expect(container.textContent).not.toContain('Offline')
expect(container.textContent).not.toContain('Proyecto activo')
})
it('renders real operational summary metrics from loaded projects', async () => {
vi.mocked(listProjects).mockResolvedValue([
{
id: 'project-online',
name: 'Pozo online',
client: 'Cliente A',
operator_name: 'Operador A',
contract_number: 'CON-1',
description: 'Con agente reciente',
is_active: true,
created_at: '2026-06-19T00:00:00Z',
last_health_report: { timestamp: '2026-06-20T17:59:30Z' },
},
{
id: 'project-offline',
name: 'Pozo offline',
client: 'Cliente B',
operator_name: 'Operador B',
contract_number: 'CON-2',
description: 'Sin heartbeat reciente',
is_active: false,
created_at: '2026-06-18T00:00:00Z',
last_health_report: { timestamp: '2026-06-20T17:55:00Z' },
},
])
await act(async () => {
root.render(
<MemoryRouter>
<ProjectListPage />
</MemoryRouter>
)
})
await flushEffects()
expect(container.textContent).toContain('Proyectos Edge')
expect(metricValue(container, 'Total proyectos')).toBe('2')
expect(metricValue(container, 'Proyectos activos')).toBe('1')
expect(metricValue(container, 'Agentes online')).toBe('1')
expect(metricValue(container, 'Agentes offline')).toBe('1')
})
it('renders compact summary cards without long metadata rows', async () => {
vi.mocked(listProjects).mockResolvedValue([
{
id: 'project-telemetry-01',
name: 'Campo Oriente',
client: 'Kyrbot Innovations SAS',
operator_name: 'KYRBOT_INNOVATIONS_SAS',
contract_number: 'CONT-2026-ANH-0651',
description: 'Proyecto operativo',
is_active: true,
created_at: '2026-06-19T00:00:00Z',
last_health_report: { timestamp: '2026-06-20T17:59:30Z' },
},
])
vi.mocked(listAssets).mockResolvedValue([
{
id: 'well-1',
project_id: 'project-telemetry-01',
code: 'W-1',
name: 'Pozo 1',
asset_type: 'POZO',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-19T00:00:00Z',
updated_at: '2026-06-19T00:00:00Z',
},
{
id: 'tank-1',
project_id: 'project-telemetry-01',
code: 'T-1',
name: 'Tanque 1',
asset_type: 'TANQUE',
is_active: true,
metadata: {},
is_collector_enabled: true,
created_at: '2026-06-19T00:00:00Z',
updated_at: '2026-06-19T00:00:00Z',
},
])
await act(async () => {
root.render(
<MemoryRouter>
<ProjectListPage />
</MemoryRouter>
)
})
await flushEffects()
const projectLink = container.querySelector('a[href="/app/admin/projects/project-telemetry-01"]')
const newProjectLink = container.querySelector('a[href="/app/admin/projects/new"]')
expect(listAssets).toHaveBeenCalledWith('project-telemetry-01')
expect(projectLink?.textContent).toContain('Campo Oriente')
expect(projectLink?.textContent).toContain('Proyecto operativo')
expect(projectLink?.textContent).toContain('Activo')
expect(projectLink?.textContent).not.toContain('Proyecto activo')
expect(projectLink?.textContent).toContain('1 pozo')
expect(projectLink?.textContent).toContain('Variables no consultadas')
expect(projectLink?.textContent).toContain('Agente online')
expect(projectLink?.textContent).not.toContain('Cliente')
expect(projectLink?.textContent).not.toContain('Kyrbot Innovations SAS')
expect(projectLink?.textContent).not.toContain('Operador')
expect(projectLink?.textContent).not.toContain('KYRBOT_INNOVATIONS_SAS')
expect(projectLink?.textContent).not.toContain('Contrato')
expect(projectLink?.textContent).not.toContain('CONT-2026-ANH-0651')
expect(projectLink?.textContent).not.toContain('ID telemetría')
expect(projectLink?.textContent).not.toContain('project-telemetry-01')
expect(projectLink?.textContent).not.toContain('Último reporte')
expect(newProjectLink?.textContent).toContain('Nuevo proyecto')
}) })
}) })

View File

@@ -1,10 +1,26 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { listProjects, EdgeProject } from '../api/projects-api' import { listProjects, EdgeProject } from '../api/projects-api'
import { Plus, Folder, Loader2, Package, Activity, CalendarDays } from 'lucide-react' import { listAssets } from '../api/wells-api'
import {
AlertTriangle,
ChevronRight,
CircleOff,
FolderKanban,
type LucideIcon,
Loader2,
Plus,
RadioTower,
ShieldCheck,
Signal,
} from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
interface ProjectSummary {
wellCount: number
}
function isAgentOnline(project: EdgeProject) { function isAgentOnline(project: EdgeProject) {
if (!project.last_health_report?.timestamp) return false if (!project.last_health_report?.timestamp) return false
@@ -12,8 +28,52 @@ function isAgentOnline(project: EdgeProject) {
return (Date.now() - lastReport) / 1000 < 60 return (Date.now() - lastReport) / 1000 < 60
} }
function statusPillClass(variant: 'success' | 'warning' | 'destructive') {
const variants = {
success: 'border-[#27d796]/25 bg-[#27d796]/10 text-[#8df0c4]',
warning: 'border-[#ffb020]/25 bg-[#ffb020]/10 text-[#ffd08a]',
destructive: 'border-destructive/25 bg-destructive/10 text-destructive',
}
return `inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${variants[variant]}`
}
function SummaryCard({ label, value, icon: Icon, tone }: {
label: string
value: number
icon: LucideIcon
tone: 'primary' | 'success' | 'destructive' | 'warning'
}) {
const toneClass = {
primary: 'text-primary bg-primary/10 border-primary/20',
success: 'text-success bg-success/10 border-success/20',
destructive: 'text-destructive bg-destructive/10 border-destructive/20',
warning: 'text-warning bg-warning/10 border-warning/20',
}[tone]
return (
<div className="rounded-xl border border-[#2a3038] bg-[#15191e] p-4 shadow-lg shadow-black/10">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-[#9aa8ba]">{label}</p>
<p className="mt-2 font-mono text-2xl font-semibold tracking-tight text-foreground">{value}</p>
</div>
<span className={`flex size-9 items-center justify-center rounded-lg border bg-[#171b20] ${toneClass}`}>
<Icon className="size-4" />
</span>
</div>
</div>
)
}
function getWellLabel(summary?: ProjectSummary) {
if (!summary) return null
return `${summary.wellCount} ${summary.wellCount === 1 ? 'pozo' : 'pozos'}`
}
export default function ProjectListPage() { export default function ProjectListPage() {
const [projects, setProjects] = useState<EdgeProject[]>([]) const [projects, setProjects] = useState<EdgeProject[]>([])
const [projectSummaries, setProjectSummaries] = useState<Record<string, ProjectSummary>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -25,105 +85,184 @@ export default function ProjectListPage() {
try { try {
const response = await listProjects() const response = await listProjects()
setProjects(response || []) setProjects(response || [])
} catch (err: any) { } catch (err: unknown) {
setError(err.message) setError(err instanceof Error ? err.message : 'Error desconocido')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
useEffect(() => {
if (projects.length === 0) {
setProjectSummaries({})
return
}
let cancelled = false
Promise.allSettled(
projects.map(async (project) => {
const assets = await listAssets(project.id)
return [project.id, { wellCount: assets.filter((asset) => asset.asset_type === 'POZO').length }] as const
})
).then((results) => {
if (cancelled) return
const summaries = results.reduce<Record<string, ProjectSummary>>((acc, result) => {
if (result.status === 'fulfilled') {
const [projectId, summary] = result.value
acc[projectId] = summary
}
return acc
}, {})
setProjectSummaries(summaries)
})
return () => {
cancelled = true
}
}, [projects])
const activeProjects = projects.filter((project) => project.is_active).length
const onlineAgents = projects.filter(isAgentOnline).length
const offlineAgents = Math.max(projects.length - onlineAgents, 0)
const summaryMetrics = [
{ label: 'Total proyectos', value: projects.length, icon: FolderKanban, tone: 'primary' as const },
{ label: 'Proyectos activos', value: activeProjects, icon: ShieldCheck, tone: 'success' as const },
{ label: 'Agentes online', value: onlineAgents, icon: Signal, tone: 'success' as const },
{ label: 'Agentes offline', value: offlineAgents, icon: CircleOff, tone: 'destructive' as const },
]
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex h-full min-h-[360px] items-center justify-center p-6">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <div className="glass-card flex flex-col items-center rounded-2xl border border-border/70 bg-card/70 px-8 py-10 text-center shadow-sm">
<span className="flex size-12 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
<Loader2 className="size-5 animate-spin" />
</span>
<h1 className="mt-4 text-base font-semibold text-foreground">Sincronizando consola Edge</h1>
<p className="mt-1 text-xs text-muted-foreground">Cargando proyectos, estados de ciclo de vida y conectividad de agentes.</p>
</div>
</div> </div>
) )
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col bg-[#090b0f] text-foreground">
{/* Header */} <div className="shrink-0 border-b border-[#2a3038] bg-[#090b0f]/95 px-4 py-5 sm:px-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-4 px-6 py-4 border-b border-border shrink-0"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-start gap-3">
<Package className="h-5 w-5 text-primary shrink-0" /> <span className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-[#ff7a1a]/20 bg-[#ff7a1a]/10 text-[#ff7a1a] shadow-sm shadow-orange-500/10">
<RadioTower className="size-5" />
</span>
<div> <div>
<h1 className="text-base font-semibold">Panel de Proyectos</h1> <p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-[#ff7a1a]">Consola SCADA</p>
<p className="text-xs text-muted-foreground">Gestion de proyectos SCADA y edge agents.</p> <h1 className="mt-1 text-2xl font-bold tracking-tight text-foreground">Proyectos Edge</h1>
<p className="mt-1 max-w-2xl text-sm text-[#9aa8ba]">
Supervisión operativa de proyectos, despliegues y agentes en campo.
</p>
</div> </div>
</div> </div>
<Link <Link
to="/app/admin/projects/new" to="/app/admin/projects/new"
className="sm:ml-auto flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors w-full sm:w-auto" className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-[#ff7a1a]/30 bg-[#ff7a1a] px-4 text-sm font-semibold text-[#090b0f] shadow-sm shadow-orange-500/20 transition-all hover:bg-[#ff8f3d] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff7a1a]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#090b0f] sm:w-auto"
> >
<Plus className="h-4 w-4" /> <Plus className="size-4" />
Nuevo Proyecto Nuevo proyecto
</Link> </Link>
</div> </div>
{/* Content */} <div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="flex-1 overflow-y-auto p-6"> {summaryMetrics.map((metric) => (
<SummaryCard key={metric.label} {...metric} />
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
{error && ( {error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3 text-destructive text-sm mb-6"> <div role="alert" className="mb-6 flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error} <AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div>
<p className="font-semibold">No se pudo cargar la consola de proyectos</p>
<p className="mt-1 text-xs text-destructive/90">{error}</p>
</div>
</div> </div>
)} )}
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center border border-dashed border-border rounded-xl"> <div className="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-dashed border-[#2a3038] bg-[#15191e] px-6 py-16 text-center shadow-xl shadow-black/15">
<Folder className="h-10 w-10 text-muted-foreground mb-3" /> <span className="flex size-14 items-center justify-center rounded-2xl border border-[#ff7a1a]/20 bg-[#ff7a1a]/10 text-[#ff7a1a]">
<p className="text-sm font-medium text-muted-foreground">No hay proyectos creados.</p> <FolderKanban className="size-7" />
</span>
<h2 className="mt-5 text-lg font-semibold text-foreground">Sin proyectos Edge registrados</h2>
<p className="mt-2 max-w-sm text-sm text-[#9aa8ba]">
Crea el primer proyecto para agrupar pozos, agentes y telemetría real de campo.
</p>
<Link
to="/app/admin/projects/new"
className="mt-5 inline-flex h-10 items-center gap-2 rounded-lg border border-[#ff7a1a]/30 bg-[#ff7a1a] px-4 text-sm font-semibold text-[#090b0f] transition-colors hover:bg-[#ff8f3d] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff7a1a]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#090b0f]"
>
<Plus className="size-4" />
Nuevo proyecto
</Link>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{projects.map((project) => ( {projects.map((project) => {
<Link key={project.id} to={`/app/admin/projects/${project.id}`} className="block group focus:outline-none"> const agentOnline = isAgentOnline(project)
<Card className="h-full border-border transition-all hover:border-primary/50 hover:bg-primary/[0.03] hover:shadow-lg hover:shadow-orange-500/5 group-focus-visible:ring-2 group-focus-visible:ring-primary/50"> const wellLabel = getWellLabel(projectSummaries[project.id])
<CardContent className="p-6">
<div className="mb-5 flex items-start justify-between gap-3"> return (
<div className="min-w-0"> <Link
<h3 className="truncate text-xl font-bold tracking-tight text-foreground transition-colors group-hover:text-primary" title={project.name}> key={project.id}
to={`/app/admin/projects/${project.id}`}
className="group block rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff7a1a]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#090b0f]"
>
<Card className="h-full overflow-hidden rounded-2xl border-[#2a3038] bg-[#15191e] shadow-lg shadow-black/10 transition-all hover:-translate-y-0.5 hover:border-[#ff7a1a]/45 hover:shadow-xl hover:shadow-orange-500/10">
<CardContent className="p-0">
<div className="flex h-full flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-[#ff7a1a]/20 bg-[#ff7a1a]/10 text-[#ff7a1a]">
<FolderKanban className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="truncate text-base font-bold tracking-tight text-foreground transition-colors group-hover:text-[#ff7a1a]" title={project.name}>
{project.name} {project.name}
</h3> </h2>
<p className="mt-1 line-clamp-2 text-sm font-medium text-muted-foreground"> <Badge className={`${statusPillClass(project.is_active ? 'success' : 'warning')} shrink-0`}>
{project.description || 'Sin descripcion'} <span className="size-1.5 rounded-full bg-current" />
</p> {project.is_active ? 'Activo' : 'Inactivo'}
</Badge>
</div> </div>
<div className="flex flex-col items-end gap-1 shrink-0"> <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-[#9aa8ba]">
<Badge className={project.is_active {project.description || 'Sin descripción operativa registrada.'}
? 'bg-success/10 text-success border-0 text-xs' </p>
: 'bg-destructive/10 text-destructive border-0 text-xs'
}>
{project.is_active ? 'Proyecto activo' : 'Proyecto inactivo'}
</Badge>
<Badge className={isAgentOnline(project)
? 'bg-success/10 text-success border-0 text-xs'
: 'bg-destructive/10 text-destructive border-0 text-xs'
}>
{isAgentOnline(project) ? 'Agente online' : 'Agente offline'}
</Badge>
</div> </div>
</div> </div>
<div className="grid gap-3 rounded-xl border border-border/60 bg-secondary/30 p-3 text-xs text-muted-foreground"> <div className="mt-auto flex items-center justify-between gap-3 border-t border-[#2a3038] pt-3 text-xs text-[#9aa8ba]">
<div className="flex items-center justify-between gap-3"> <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-2 font-medium uppercase tracking-wider text-[10px] text-muted-foreground"> {wellLabel && <span>{wellLabel}</span>}
<Activity className="h-3.5 w-3.5 text-primary" /> <span>Variables no consultadas</span>
Telemetria <span className={`inline-flex items-center gap-1.5 ${agentOnline ? 'text-[#8df0c4]' : 'text-destructive'}`}>
<span className="size-1.5 rounded-full bg-current" />
{agentOnline ? 'Agente online' : 'Agente offline'}
</span> </span>
<span className="font-mono text-sm font-semibold text-foreground">{project.id.split('-')[0]}</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <span className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[#2a3038] bg-[#171b20] text-[#9aa8ba] transition-colors group-hover:border-[#ff7a1a]/40 group-hover:text-[#ff7a1a]">
<span className="flex items-center gap-2 font-medium uppercase tracking-wider text-[10px] text-muted-foreground"> <ChevronRight className="size-4 transition-transform group-hover:translate-x-0.5" />
<CalendarDays className="h-3.5 w-3.5 text-primary" />
Creado
</span> </span>
<span className="text-sm font-semibold text-foreground">{new Date(project.created_at).toLocaleDateString()}</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
))} )
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -33,11 +33,11 @@
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
/* Estados semánticos */ /* Estados semánticos */
--color-success: oklch(0.70 0.16 165); --color-success: var(--success);
--color-warning: oklch(0.75 0.15 75); --color-warning: var(--warning);
--color-info: oklch(0.65 0.18 220); --color-info: var(--info);
--color-destructive: oklch(0.60 0.22 25); --color-destructive: var(--destructive);
--color-destructive-foreground: oklch(0.95 0 0); --color-destructive-foreground: var(--destructive-foreground);
/* Bordes y foco */ /* Bordes y foco */
--color-border: var(--border); --color-border: var(--border);
@@ -45,11 +45,11 @@
--color-ring: var(--ring); --color-ring: var(--ring);
/* Gráficos — Recharts */ /* Gráficos — Recharts */
--color-chart-1: oklch(0.70 0.16 165); --color-chart-1: var(--chart-1);
--color-chart-2: oklch(0.65 0.18 220); --color-chart-2: var(--chart-2);
--color-chart-3: oklch(0.75 0.15 75); --color-chart-3: var(--chart-3);
--color-chart-4: oklch(0.60 0.20 25); --color-chart-4: var(--chart-4);
--color-chart-5: oklch(0.55 0.12 280); --color-chart-5: var(--chart-5);
/* Radio */ /* Radio */
--radius: 0.5rem; --radius: 0.5rem;
@@ -57,38 +57,39 @@
/* Variables CSS para retrocompatibilidad con componentes que usan var() */ /* Variables CSS para retrocompatibilidad con componentes que usan var() */
:root { :root {
--background: #0a0a0a; --background: #090b0f;
--card: #0d1117; --card: #15191e;
--sidebar: #0b0f14; --sidebar: #0b0f14;
--secondary: rgba(255, 255, 255, 0.08); --secondary: #24282e;
--muted: rgba(255, 255, 255, 0.06); --muted: #171b20;
--input: rgba(255, 255, 255, 0.06); --input: #171b20;
--popover: #0d1117; --popover: #15191e;
--foreground: #e5e7eb; --foreground: #f4f7fb;
--muted-foreground: #9ca3af; --muted-foreground: #9aa8ba;
--secondary-foreground: #d1d5db; --secondary-foreground: #d9e1ec;
--card-foreground: #e5e7eb; --card-foreground: #f4f7fb;
--popover-foreground: #e5e7eb; --popover-foreground: #f4f7fb;
--primary: #f97316; --primary: #ff7a1a;
--primary-foreground: #111827; --primary-foreground: #0b0f14;
--primary-muted: rgba(249, 115, 22, 0.10); --primary-muted: rgba(255, 122, 26, 0.10);
--primary-glow: rgba(249, 115, 22, 0.16); --primary-glow: rgba(255, 122, 26, 0.10);
--accent: #f97316; --accent: #ff7a1a;
--accent-foreground: #111827; --accent-foreground: #0b0f14;
--success: oklch(0.70 0.16 165); --success: #27d796;
--warning: oklch(0.75 0.15 75); --warning: #ffb020;
--info: oklch(0.65 0.18 220); --info: #00b7ff;
--destructive: oklch(0.60 0.22 25); --destructive: #ff4655;
--border: rgba(255, 255, 255, 0.08); --destructive-foreground: #ffffff;
--sidebar-border: rgba(255, 255, 255, 0.08); --border: #2a3038;
--ring: #f97316; --sidebar-border: #232831;
--card-border: rgba(249, 115, 22, 0.15); --ring: #ff7a1a;
--card-border-hover: rgba(249, 115, 22, 0.4); --card-border: #2a3038;
--chart-1: oklch(0.70 0.16 165); --card-border-hover: rgba(255, 122, 26, 0.45);
--chart-2: oklch(0.65 0.18 220); --chart-1: #27d796;
--chart-3: oklch(0.75 0.15 75); --chart-2: #00b7ff;
--chart-4: oklch(0.60 0.20 25); --chart-3: #ffb020;
--chart-5: oklch(0.55 0.12 280); --chart-4: #ff4655;
--chart-5: #a8b3c4;
--radius: 0.5rem; --radius: 0.5rem;
} }
@@ -103,10 +104,7 @@ html {
} }
body { body {
background: background: var(--background);
radial-gradient(circle at top left, var(--primary-glow), transparent 34rem),
radial-gradient(circle at bottom right, rgba(234, 88, 12, 0.12), transparent 30rem),
var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -115,7 +113,7 @@ body {
/* Focus visible global */ /* Focus visible global */
*:focus-visible { *:focus-visible {
outline: 2px solid rgba(249, 115, 22, 0.5); outline: 2px solid rgba(255, 122, 26, 0.5);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -131,7 +129,7 @@ select:focus-visible,
.glass-card { .glass-card {
background: color-mix(in srgb, var(--card) 84%, transparent); background: color-mix(in srgb, var(--card) 84%, transparent);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.34), 0 0 50px rgba(249, 115, 22, 0.06); box-shadow: 0 24px 80px rgb(0 0 0 / 0.34), 0 0 50px rgba(255, 122, 26, 0.04);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
} }
@@ -162,7 +160,7 @@ select:focus-visible,
::-webkit-scrollbar-thumb:hover, ::-webkit-scrollbar-thumb:hover,
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(249, 115, 22, 0.5); background: rgba(255, 122, 26, 0.5);
} }
/* 2. Stealth Sidebar Scrollbar (Appears on Hover) */ /* 2. Stealth Sidebar Scrollbar (Appears on Hover) */
@@ -173,7 +171,7 @@ select:focus-visible,
} }
.custom-sidebar-scroll:hover { .custom-sidebar-scroll:hover {
scrollbar-color: rgba(249, 115, 22, 0.3) transparent; scrollbar-color: rgba(255, 122, 26, 0.3) transparent;
} }
.custom-sidebar-scroll::-webkit-scrollbar { .custom-sidebar-scroll::-webkit-scrollbar {
@@ -190,11 +188,11 @@ select:focus-visible,
} }
.custom-sidebar-scroll:hover::-webkit-scrollbar-thumb { .custom-sidebar-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(249, 115, 22, 0.3); background: rgba(255, 122, 26, 0.3);
} }
.custom-sidebar-scroll::-webkit-scrollbar-thumb:hover { .custom-sidebar-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(249, 115, 22, 0.6); background: rgba(255, 122, 26, 0.6);
} }
/* Firefox Universal Support */ /* Firefox Universal Support */

View File

@@ -7,17 +7,20 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const globalsCss = fs.readFileSync(path.resolve(__dirname, './globals.css'), 'utf8') const globalsCss = fs.readFileSync(path.resolve(__dirname, './globals.css'), 'utf8')
describe('admin global visual language', () => { describe('admin global visual language', () => {
it('defines landing-inspired local dark/orange tokens', () => { it('defines v0-matched near-black, charcoal, and orange tokens', () => {
expect(globalsCss).toContain('--background: #0a0a0a') expect(globalsCss).toContain('--background: #090b0f')
expect(globalsCss).toContain('--card: #0d1117') expect(globalsCss).toContain('--sidebar: #0b0f14')
expect(globalsCss).toContain('--primary: #f97316') expect(globalsCss).toContain('--card: #15191e')
expect(globalsCss).toContain('--primary-glow: rgba(249, 115, 22, 0.16)') expect(globalsCss).toContain('--primary: #ff7a1a')
expect(globalsCss).toContain('--ring: #f97316') expect(globalsCss).toContain('--ring: #ff7a1a')
}) })
it('uses orange glass and scrollbar accents instead of emerald brand accents', () => { it('uses screenshot palette accents and avoids blue-navy base tokens', () => {
expect(globalsCss).toContain('rgba(249, 115, 22, 0.15)') expect(globalsCss).toContain('--muted-foreground: #9aa8ba')
expect(globalsCss).toContain('rgba(249, 115, 22, 0.6)') expect(globalsCss).toContain('--success: #27d796')
expect(globalsCss).toContain('--info: #00b7ff')
expect(globalsCss).toContain('--destructive: #ff4655')
expect(globalsCss).toContain('rgba(255, 122, 26, 0.6)')
expect(globalsCss).not.toContain('0.65 0.18 160') expect(globalsCss).not.toContain('0.65 0.18 160')
}) })
}) })

3102
services/frontend-console/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8271
services/frontend-pwa/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -51,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin json-generator
# 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
RUN cargo build --release --bin json-generator RUN cargo build --release --bin json-generator
# ============================================================================= # =============================================================================

5135
services/landing/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocsLayout } from "@/components/docs/DocsLayout";
import { DocsTopicContent } from "@/components/docs/DocsTopicContent";
import { docsTopics, getDocsTopic, type DocsSlug } from "@/content/docs";
type DocsTopicPageProps = {
params: Promise<{ slug: string }>;
};
export const dynamicParams = false;
export function generateStaticParams() {
return docsTopics.map((topic) => ({ slug: topic.slug }));
}
export async function generateMetadata({ params }: DocsTopicPageProps): Promise<Metadata> {
const { slug } = await params;
const topic = getDocsTopic(slug);
if (!topic) {
return {
title: "Documentation topic not found",
};
}
return {
title: topic.title,
description: topic.description,
alternates: {
canonical: `/docs/${topic.slug}`,
},
};
}
export default async function DocsTopicPage({ params }: DocsTopicPageProps) {
const { slug } = await params;
const topic = getDocsTopic(slug);
if (!topic) {
notFound();
}
return (
<DocsLayout activeSlug={topic.slug as DocsSlug}>
<DocsTopicContent slug={topic.slug as DocsSlug} />
</DocsLayout>
);
}

View File

@@ -0,0 +1,160 @@
/** @vitest-environment jsdom */
import { render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import DocsIndexPage from "./page";
import DocsTopicPage from "./[slug]/page";
import { docsTopics, getDocsTopics } from "@/content/docs";
const requiredDocsSlugs = [
"architecture",
"backend-api",
"database",
"admin-dashboard",
"field-pwa",
"infrastructure",
"simulators",
];
const unsupportedDocsClaims = [/\bDokploy\b/i, /\bguaranteed\b/i, /\b100%\b/i, /\bfully compliant\b/i];
function getDocsContentText() {
return ([...getDocsTopics("en"), ...getDocsTopics("es")])
.flatMap((topic) => [
topic.title,
topic.description,
...topic.sourceRefs,
...topic.pendingValidation,
...topic.blocks.flatMap((block) => {
if (block.type === "paragraph") {
return [block.text];
}
if (block.type === "callout") {
return [block.title, block.body];
}
if (block.type === "list") {
return block.items;
}
if (block.type === "table") {
return [...block.columns, ...block.rows.flat()];
}
return [block.code];
}),
])
.join("\n");
}
afterEach(() => {
localStorage.clear();
});
vi.mock("next/image", () => ({
default: ({ alt, ...props }: { alt: string; priority?: boolean; [key: string]: unknown }) => {
delete props.priority;
return (
// eslint-disable-next-line @next/next/no-img-element
<img alt={alt} {...props} />
);
},
}));
vi.mock("next/navigation", () => ({
notFound: vi.fn(() => {
throw new Error("notFound");
}),
usePathname: () => "/docs/backend-api",
}));
describe("docs routes", () => {
it("keeps the required docs registry topics backed by sources and validation notes", () => {
expect(docsTopics.map((topic) => topic.slug)).toEqual(requiredDocsSlugs);
for (const topic of docsTopics) {
expect(topic.sourceRefs.length).toBeGreaterThan(0);
expect(topic.sourceRefs.every((sourceRef) => sourceRef.trim().length > 0)).toBe(true);
expect(topic.pendingValidation.length).toBeGreaterThan(0);
expect(topic.pendingValidation.every((item) => item.trim().length > 0)).toBe(true);
}
});
it("does not publish unsupported product docs or absolute marketing claims", () => {
const allDocsContent = getDocsContentText();
for (const claim of unsupportedDocsClaims) {
expect(allDocsContent).not.toMatch(claim);
}
});
it("renders overview links for every documentation topic", () => {
render(<DocsIndexPage />);
expect(
screen.getByRole("heading", { name: /omnioilpersonal documentation/i }),
).toBeInTheDocument();
const topicsRegion = screen.getByRole("region", { name: /documentation topics/i });
for (const topic of docsTopics) {
expect(within(topicsRegion).getByRole("link", { name: new RegExp(topic.title, "i") }))
.toHaveAttribute("href", `/docs/${topic.slug}`);
}
});
it("renders topic title, content, source basis, and active sidebar state", async () => {
const topic = docsTopics.find((item) => item.slug === "backend-api");
expect(topic).toBeDefined();
render(await DocsTopicPage({ params: Promise.resolve({ slug: "backend-api" }) }));
expect(screen.getByRole("heading", { name: topic?.title })).toBeInTheDocument();
expect(screen.getByText(topic?.description ?? "")).toBeInTheDocument();
expect(screen.getByText(/openapi publication note/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /source basis/i })).toBeInTheDocument();
const sidebar = screen.getByRole("complementary", { name: /documentation topics/i });
const activeLink = within(sidebar).getByRole("link", { name: topic?.title });
expect(activeLink).toHaveAttribute("href", "/docs/backend-api");
expect(activeLink).toHaveAttribute("aria-current", "page");
});
it("exposes accessible docs navigation to every topic", () => {
render(<DocsIndexPage />);
const sidebar = screen.getByRole("complementary", { name: /documentation topics/i });
expect(within(sidebar).getByRole("link", { name: /overview/i })).toHaveAttribute(
"aria-current",
"page",
);
for (const topic of docsTopics) {
expect(within(sidebar).getByRole("link", { name: topic.title })).toHaveAttribute(
"href",
`/docs/${topic.slug}`,
);
}
});
it("renders docs content in Spanish when the landing language is Spanish", async () => {
localStorage.setItem("omnioil-lang", "es");
render(await DocsTopicPage({ params: Promise.resolve({ slug: "backend-api" }) }));
expect(screen.getByRole("heading", { name: "Backend API" })).toBeInTheDocument();
expect(screen.getByText(/grupos de rutas expuestos por el backend Rust Axum/i)).toBeInTheDocument();
expect(screen.getByText(/nota de publicación OpenAPI/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /base de fuentes/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /pendiente por validar/i })).toBeInTheDocument();
const sidebar = screen.getByRole("complementary", { name: /temas de documentación/i });
expect(within(sidebar).getByRole("link", { name: /resumen/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { DocsIndexContent } from "@/components/docs/DocsIndexContent";
import { DocsLayout } from "@/components/docs/DocsLayout";
import { docsOverview } from "@/content/docs";
export const metadata: Metadata = {
title: docsOverview.title,
description: docsOverview.description,
alternates: {
canonical: "/docs",
},
};
export default function DocsIndexPage() {
return (
<DocsLayout>
<DocsIndexContent />
</DocsLayout>
);
}

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import robots from "./robots"; import robots from "./robots";
import sitemap from "./sitemap"; import sitemap from "./sitemap";
import { getLandingBaseUrl, getLandingUrl, siteConfig } from "@/config/site"; import { getLandingBaseUrl, getLandingUrl, siteConfig } from "@/config/site";
import { docsRoutes } from "@/content/docs";
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
@@ -64,6 +65,10 @@ describe("landing SEO foundation", () => {
expect.objectContaining({ url: "https://omnioil.app/security", priority: 0.7 }), expect.objectContaining({ url: "https://omnioil.app/security", priority: 0.7 }),
expect.objectContaining({ url: "https://omnioil.app/privacy", priority: 0.6 }), expect.objectContaining({ url: "https://omnioil.app/privacy", priority: 0.6 }),
expect.objectContaining({ url: "https://omnioil.app/terms", priority: 0.4 }), expect.objectContaining({ url: "https://omnioil.app/terms", priority: 0.4 }),
expect.objectContaining({ url: "https://omnioil.app/docs", priority: 0.8 }),
...docsRoutes.map((route) => (
expect.objectContaining({ url: `https://omnioil.app${route}`, priority: 0.6 })
)),
]); ]);
}); });
}); });

View File

@@ -1,11 +1,14 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { getLandingUrl } from "@/config/site"; import { getLandingUrl } from "@/config/site";
import { docsRoutes } from "@/content/docs";
const landingRoutes = [ const landingRoutes = [
{ path: "/", priority: 1 }, { path: "/", priority: 1 },
{ path: "/security", priority: 0.7 }, { path: "/security", priority: 0.7 },
{ path: "/privacy", priority: 0.6 }, { path: "/privacy", priority: 0.6 },
{ path: "/terms", priority: 0.4 }, { path: "/terms", priority: 0.4 },
{ path: "/docs", priority: 0.8 },
...docsRoutes.map((path) => ({ path, priority: 0.6 })),
] as const; ] as const;
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {

View File

@@ -2,6 +2,15 @@ import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const docsStaticFiles = [
"src/app/docs/page.tsx",
"src/app/docs/[slug]/page.tsx",
"src/components/docs/DocsBlockRenderer.tsx",
"src/components/docs/DocsLayout.tsx",
"src/components/docs/DocsPageHeader.tsx",
"src/components/docs/DocsSidebar.tsx",
] as const;
const projectRoot = process.cwd(); const projectRoot = process.cwd();
function readSource(relativePath: string) { function readSource(relativePath: string) {
@@ -16,6 +25,15 @@ describe("landing static-first and reduced-motion invariants", () => {
expect(source.startsWith("'use client'")).toBe(false); expect(source.startsWith("'use client'")).toBe(false);
}); });
it("keeps docs route and renderer files free from top-level client boundaries", () => {
for (const file of docsStaticFiles) {
const source = readSource(file).trimStart();
expect(source.startsWith('"use client"')).toBe(false);
expect(source.startsWith("'use client'")).toBe(false);
}
});
it("keeps reduced-motion support in the dynamic background", () => { it("keeps reduced-motion support in the dynamic background", () => {
const source = readSource("src/components/DynamicBackground.tsx"); const source = readSource("src/components/DynamicBackground.tsx");

View File

@@ -0,0 +1,91 @@
import type { DocsBlock } from "@/content/docs";
interface DocsBlockRendererProps {
blocks: DocsBlock[];
}
const calloutStyles = {
verified: "border-emerald-500/25 bg-emerald-500/[0.06] text-emerald-200",
pending: "border-orange-500/25 bg-orange-500/[0.08] text-orange-100",
} as const;
export function DocsBlockRenderer({ blocks }: DocsBlockRendererProps) {
return (
<div className="space-y-6">
{blocks.map((block, index) => {
if (block.type === "paragraph") {
return (
<p key={index} className="text-sm leading-7 text-[#d1d5db] sm:text-base">
{block.text}
</p>
);
}
if (block.type === "callout") {
return (
<aside
key={index}
className={`rounded-2xl border p-5 ${calloutStyles[block.tone]}`}
>
<h3 className="text-sm font-semibold text-white">{block.title}</h3>
<p className="mt-2 text-sm leading-6 text-[#d1d5db]">{block.body}</p>
</aside>
);
}
if (block.type === "list") {
return (
<ul key={index} className="space-y-3 text-sm leading-6 text-[#d1d5db]">
{block.items.map((item) => (
<li key={item} className="flex gap-3">
<span className="mt-2 h-1.5 w-1.5 flex-none rounded-full bg-orange-400" />
<span>{item}</span>
</li>
))}
</ul>
);
}
if (block.type === "table") {
return (
<div key={index} className="overflow-hidden rounded-2xl border border-white/[0.08]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/[0.08] text-left text-sm">
<thead className="bg-white/[0.03] text-xs uppercase tracking-wider text-[#9ca3af]">
<tr>
{block.columns.map((column) => (
<th key={column} className="px-4 py-3 font-semibold">
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06] text-[#d1d5db]">
{block.rows.map((row) => (
<tr key={row.join("|")}>
{row.map((cell, cellIndex) => (
<td key={`${cell}-${cellIndex}`} className="px-4 py-3 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
return (
<pre
key={index}
className="overflow-x-auto rounded-2xl border border-white/[0.08] bg-black/40 p-5 text-sm text-orange-100"
>
<code>{block.code}</code>
</pre>
);
})}
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { DocsPageHeaderContent } from "@/components/docs/DocsPageHeaderContent";
import { docsUi, getDocsOverview, getDocsTopics } from "@/content/docs";
import { useLanguage } from "@/i18n/LanguageContext";
export function DocsIndexContent() {
const { lang } = useLanguage();
const overview = getDocsOverview(lang);
const topics = getDocsTopics(lang);
return (
<>
<DocsPageHeaderContent title={overview.title} description={overview.description} />
<section className="grid gap-4 md:grid-cols-2" aria-label={docsUi.topicsAria[lang]}>
{topics.map((topic) => (
<Link
key={topic.slug}
href={`/docs/${topic.slug}`}
className="group rounded-2xl border border-white/[0.08] bg-[#0d1117]/80 p-5 transition-colors hover:border-orange-500/30"
>
<div className="mb-3 inline-flex rounded-full border border-white/[0.08] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-[#9ca3af]">
{topic.status === "verified" ? docsUi.verified[lang] : docsUi.pending[lang]}
</div>
<h2 className="text-lg font-semibold text-white group-hover:text-orange-300">
{topic.title}
</h2>
<p className="mt-2 text-sm leading-6 text-[#9ca3af]">{topic.description}</p>
</Link>
))}
</section>
</>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { Footer } from "@/components/layout/Footer";
import { Navbar } from "@/components/layout/Navbar";
import { DocsSidebar } from "@/components/docs/DocsSidebar";
import { LanguageProvider } from "@/i18n/LanguageContext";
import type { DocsSlug } from "@/content/docs";
interface DocsLayoutProps {
activeSlug?: DocsSlug;
children: ReactNode;
}
export function DocsLayout({ activeSlug, children }: DocsLayoutProps) {
return (
<LanguageProvider>
<Navbar />
<main className="flex-1 bg-[#0a0a0a]">
<section className="relative overflow-hidden pt-32 pb-20">
<div className="pointer-events-none absolute inset-x-0 top-0 h-[420px] bg-[radial-gradient(ellipse_at_top,rgba(249,115,22,0.12),transparent_60%)]" />
<div className="relative z-10 mx-auto grid max-w-7xl gap-8 px-4 sm:px-6 lg:grid-cols-[280px_1fr] lg:px-8">
<DocsSidebar activeSlug={activeSlug} />
<article>{children}</article>
</div>
</section>
</main>
<Footer />
</LanguageProvider>
);
}

View File

@@ -0,0 +1,12 @@
import { DocsPageHeaderContent } from "@/components/docs/DocsPageHeaderContent";
import type { DocsTopic } from "@/content/docs";
interface DocsPageHeaderProps {
title: string;
description: string;
topic?: DocsTopic;
}
export function DocsPageHeader({ title, description, topic }: DocsPageHeaderProps) {
return <DocsPageHeaderContent title={title} description={description} topic={topic} />;
}

View File

@@ -0,0 +1,38 @@
"use client";
import { docsUi, type DocsTopic } from "@/content/docs";
import { useLanguage } from "@/i18n/LanguageContext";
interface DocsPageHeaderContentProps {
title: string;
description: string;
topic?: DocsTopic;
}
export function DocsPageHeaderContent({ title, description, topic }: DocsPageHeaderContentProps) {
const { lang } = useLanguage();
return (
<header className="mb-10 border-b border-white/[0.06] pb-8">
<div className="mb-4 inline-flex rounded-full border border-orange-500/20 bg-orange-500/5 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-orange-400">
{docsUi.badge[lang]}
</div>
<h1 className="max-w-4xl text-3xl font-bold tracking-tight text-white sm:text-4xl md:text-5xl">
{title}
</h1>
<p className="mt-5 max-w-3xl text-base leading-7 text-[#d1d5db] sm:text-lg">
{description}
</p>
{topic ? (
<div className="mt-6 flex flex-wrap gap-2 text-xs text-[#9ca3af]">
<span className="rounded-full border border-white/[0.08] px-3 py-1">
{docsUi.status[lang]}: {topic.status === "verified" ? docsUi.verified[lang] : docsUi.pending[lang]}
</span>
<span className="rounded-full border border-white/[0.08] px-3 py-1">
{docsUi.sources[lang]}: {topic.sourceRefs.length}
</span>
</div>
) : null}
</header>
);
}

View File

@@ -0,0 +1,10 @@
import { DocsSidebarContent } from "@/components/docs/DocsSidebarContent";
import type { DocsSlug } from "@/content/docs";
interface DocsSidebarProps {
activeSlug?: DocsSlug;
}
export function DocsSidebar({ activeSlug }: DocsSidebarProps) {
return <DocsSidebarContent activeSlug={activeSlug} />;
}

View File

@@ -0,0 +1,50 @@
"use client";
import Link from "next/link";
import { useLanguage } from "@/i18n/LanguageContext";
import { docsUi, getDocsTopics, type DocsSlug } from "@/content/docs";
interface DocsSidebarContentProps {
activeSlug?: DocsSlug;
}
export function DocsSidebarContent({ activeSlug }: DocsSidebarContentProps) {
const { lang } = useLanguage();
const topics = getDocsTopics(lang);
return (
<aside className="lg:sticky lg:top-24 lg:self-start" aria-label={docsUi.topicsAria[lang]}>
<div className="rounded-2xl border border-white/[0.08] bg-[#0d1117]/80 p-4">
<Link
href="/docs"
className={`block rounded-xl px-3 py-2 text-sm transition-colors ${
activeSlug ? "text-[#9ca3af] hover:text-orange-400" : "bg-orange-500/10 text-orange-300"
}`}
aria-current={activeSlug ? undefined : "page"}
>
{docsUi.overview[lang]}
</Link>
<div className="mt-3 border-t border-white/[0.06] pt-3">
{topics.map((topic) => {
const active = topic.slug === activeSlug;
return (
<Link
key={topic.slug}
href={`/docs/${topic.slug}`}
className={`block rounded-xl px-3 py-2 text-sm transition-colors ${
active
? "bg-orange-500/10 text-orange-300"
: "text-[#9ca3af] hover:bg-white/[0.03] hover:text-orange-400"
}`}
aria-current={active ? "page" : undefined}
>
{topic.title}
</Link>
);
})}
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { DocsBlockRenderer } from "@/components/docs/DocsBlockRenderer";
import { DocsPageHeaderContent } from "@/components/docs/DocsPageHeaderContent";
import { docsUi, getDocsTopic, type DocsSlug } from "@/content/docs";
import { useLanguage } from "@/i18n/LanguageContext";
interface DocsTopicContentProps {
slug: DocsSlug;
}
export function DocsTopicContent({ slug }: DocsTopicContentProps) {
const { lang } = useLanguage();
const topic = getDocsTopic(slug, lang);
if (!topic) {
return null;
}
return (
<>
<DocsPageHeaderContent title={topic.title} description={topic.description} topic={topic} />
<div className="space-y-10">
<DocsBlockRenderer blocks={topic.blocks} />
<section className="rounded-2xl border border-orange-500/25 bg-orange-500/[0.08] p-5">
<h2 className="text-lg font-semibold text-white">{docsUi.pendingValidation[lang]}</h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-[#d1d5db]">
{topic.pendingValidation.map((item) => (
<li key={item} className="flex gap-3">
<span className="mt-2 h-1.5 w-1.5 flex-none rounded-full bg-orange-400" />
<span>{item}</span>
</li>
))}
</ul>
</section>
<section className="rounded-2xl border border-white/[0.08] bg-[#0d1117]/80 p-5">
<h2 className="text-lg font-semibold text-white">{docsUi.sourceBasis[lang]}</h2>
<ul className="mt-4 space-y-2 text-sm text-[#9ca3af]">
{topic.sourceRefs.map((source) => (
<li key={source} className="font-mono text-xs sm:text-sm">
{source}
</li>
))}
</ul>
</section>
</div>
</>
);
}

View File

@@ -4,14 +4,21 @@ import { useEffect } from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { RequestAccessSection } from "./RequestAccessSection"; import { RequestAccessSection } from "./RequestAccessSection";
import { Footer } from "./layout/Footer";
import { Navbar } from "./layout/Navbar"; import { Navbar } from "./layout/Navbar";
import { LanguageProvider } from "@/i18n/LanguageContext"; import { LanguageProvider } from "@/i18n/LanguageContext";
const mockUsePathname = vi.fn(() => "/");
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
default: ({ alt, ...props }: { alt: string; [key: string]: unknown }) => ( default: ({ alt, ...props }: { alt: string; priority?: boolean; [key: string]: unknown }) => {
delete props.priority;
return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img alt={alt} {...props} /> <img alt={alt} {...props} />
), );
},
})); }));
vi.mock("@marsidev/react-turnstile", () => ({ vi.mock("@marsidev/react-turnstile", () => ({
@@ -24,12 +31,18 @@ vi.mock("@marsidev/react-turnstile", () => ({
}, },
})); }));
vi.mock("next/navigation", () => ({
usePathname: () => mockUsePathname(),
}));
function renderWithLanguage(ui: React.ReactNode) { function renderWithLanguage(ui: React.ReactNode) {
return render(<LanguageProvider>{ui}</LanguageProvider>); return render(<LanguageProvider>{ui}</LanguageProvider>);
} }
describe("landing accessibility islands", () => { describe("landing accessibility islands", () => {
it("exposes mobile navigation state and closes with Escape", () => { it("exposes mobile navigation state and closes with Escape", () => {
mockUsePathname.mockReturnValue("/");
renderWithLanguage(<Navbar />); renderWithLanguage(<Navbar />);
const menuButton = screen.getByRole("button", { name: /open navigation menu/i }); const menuButton = screen.getByRole("button", { name: /open navigation menu/i });
@@ -48,6 +61,35 @@ describe("landing accessibility islands", () => {
expect(menuButton).toHaveAttribute("aria-expanded", "false"); expect(menuButton).toHaveAttribute("aria-expanded", "false");
}); });
it("exposes Docs in desktop and mobile navigation with active state", () => {
mockUsePathname.mockReturnValue("/docs/backend-api");
renderWithLanguage(<Navbar />);
const desktopDocsLink = screen.getByRole("link", { name: /docs/i });
expect(desktopDocsLink).toHaveAttribute("href", "/docs");
expect(desktopDocsLink).toHaveAttribute("aria-current", "page");
fireEvent.click(screen.getByRole("button", { name: /open navigation menu/i }));
const docsLinks = screen.getAllByRole("link", { name: /docs/i });
expect(docsLinks).toHaveLength(2);
expect(docsLinks[1]).toHaveAttribute("href", "/docs");
expect(docsLinks[1]).toHaveAttribute("aria-current", "page");
});
it("links footer documentation resources to real app destinations", () => {
renderWithLanguage(<Footer />);
expect(screen.getByRole("link", { name: /documentation/i })).toHaveAttribute("href", "/docs");
expect(screen.getByRole("link", { name: /api reference/i })).toHaveAttribute(
"href",
"/docs/backend-api",
);
});
it("renders request-access field associations and optional WhatsApp field", async () => { it("renders request-access field associations and optional WhatsApp field", async () => {
renderWithLanguage(<RequestAccessSection />); renderWithLanguage(<RequestAccessSection />);

View File

@@ -22,8 +22,8 @@ export function Footer() {
{ label: get("footer.contact", lang), href: "mailto:soporte@omnioil.app" }, { label: get("footer.contact", lang), href: "mailto:soporte@omnioil.app" },
], ],
[get("footer.resources", lang)]: [ [get("footer.resources", lang)]: [
{ label: get("footer.docs", lang), href: "#" }, { label: get("footer.docs", lang), href: "/docs" },
{ label: get("footer.api", lang), href: "#" }, { label: get("footer.api", lang), href: "/docs/backend-api" },
{ label: get("footer.resolution", lang), href: "#" }, { label: get("footer.resolution", lang), href: "#" },
{ label: get("footer.status", lang), href: "#" }, { label: get("footer.status", lang), href: "#" },
], ],

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import { usePathname } from "next/navigation";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ArrowRight, Menu, X } from "lucide-react"; import { ArrowRight, Menu, X } from "lucide-react";
import { useLanguage } from "@/i18n/LanguageContext"; import { useLanguage } from "@/i18n/LanguageContext";
@@ -16,6 +17,7 @@ export function Navbar() {
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const { lang } = useLanguage(); const { lang } = useLanguage();
const pathname = usePathname() ?? "";
useEffect(() => { useEffect(() => {
const handler = () => setScrolled(window.scrollY > 20); const handler = () => setScrolled(window.scrollY > 20);
@@ -39,6 +41,7 @@ export function Navbar() {
{ label: get("nav.architecture", lang), href: "#architecture" }, { label: get("nav.architecture", lang), href: "#architecture" },
{ label: get("nav.pricing", lang), href: "#pricing" }, { label: get("nav.pricing", lang), href: "#pricing" },
{ label: get("nav.compliance", lang), href: "#compliance" }, { label: get("nav.compliance", lang), href: "#compliance" },
{ label: get("nav.docs", lang), href: "/docs", active: pathname.startsWith("/docs") },
]; ];
return ( return (
@@ -76,10 +79,13 @@ export function Navbar() {
<motion.a <motion.a
key={l.href} key={l.href}
href={l.href} href={l.href}
aria-current={l.active ? "page" : undefined}
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + i * 0.05, duration: 0.5 }} transition={{ delay: 0.3 + i * 0.05, duration: 0.5 }}
className="relative px-3 py-2 text-sm text-[#9ca3af] hover:text-orange-400 transition-colors duration-200 rounded-md hover:bg-white/[0.03]" className={`relative px-3 py-2 text-sm transition-colors duration-200 rounded-md hover:bg-white/[0.03] ${
l.active ? "text-orange-400" : "text-[#9ca3af] hover:text-orange-400"
}`}
> >
{l.label} {l.label}
</motion.a> </motion.a>
@@ -153,7 +159,10 @@ export function Navbar() {
<a <a
key={l.href} key={l.href}
href={l.href} href={l.href}
className="block px-3 py-2.5 text-sm text-[#9ca3af] hover:text-orange-400 rounded-lg hover:bg-white/[0.04]" aria-current={l.active ? "page" : undefined}
className={`block px-3 py-2.5 text-sm rounded-lg hover:bg-white/[0.04] ${
l.active ? "text-orange-400" : "text-[#9ca3af] hover:text-orange-400"
}`}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
> >
{l.label} {l.label}

View File

@@ -0,0 +1,531 @@
import type { Language } from "@/i18n/translations";
export type DocsSlug =
| "architecture"
| "backend-api"
| "database"
| "admin-dashboard"
| "field-pwa"
| "infrastructure"
| "simulators";
export type DocsStatus = "verified" | "pending";
type LocalizedText = Record<Language, string>;
type LocalizedTextList = Record<Language, string[]>;
type LocalizedRows = Record<Language, string[][]>;
export type DocsBlock =
| { type: "paragraph"; text: string }
| { type: "callout"; tone: DocsStatus; title: string; body: string }
| { type: "list"; items: string[] }
| { type: "table"; columns: string[]; rows: string[][] }
| { type: "code"; language: "bash" | "text"; code: string };
type LocalizedDocsBlock =
| { type: "paragraph"; text: LocalizedText }
| { type: "callout"; tone: DocsStatus; title: LocalizedText; body: LocalizedText }
| { type: "list"; items: LocalizedTextList }
| { type: "table"; columns: LocalizedTextList; rows: LocalizedRows }
| { type: "code"; language: "bash" | "text"; code: string };
interface LocalizedDocsTopic {
slug: DocsSlug;
title: LocalizedText;
description: LocalizedText;
status: DocsStatus;
sourceRefs: string[];
blocks: LocalizedDocsBlock[];
pendingValidation: LocalizedTextList;
}
export interface DocsTopic {
slug: DocsSlug;
title: string;
description: string;
status: DocsStatus;
sourceRefs: string[];
blocks: DocsBlock[];
pendingValidation: string[];
}
export const docsUi = {
badge: { en: "Documentation", es: "Documentación" },
overview: { en: "Overview", es: "Resumen" },
topicsAria: { en: "Documentation topics", es: "Temas de documentación" },
verified: { en: "Verified", es: "Verificado" },
pending: { en: "Pending", es: "Pendiente" },
status: { en: "Status", es: "Estado" },
sources: { en: "Sources", es: "Fuentes" },
pendingValidation: { en: "Pending validation", es: "Pendiente por validar" },
sourceBasis: { en: "Source basis", es: "Base de fuentes" },
} as const;
const localizedDocsTopics: LocalizedDocsTopic[] = [
{
slug: "architecture",
title: { en: "System architecture", es: "Arquitectura del sistema" },
description: {
en: "How the OmniOilPersonal services fit together across landing, backend, field/admin UIs, telemetry, MQTT, and operations infrastructure.",
es: "Cómo encajan los servicios de OmniOilPersonal entre landing, backend, interfaces de campo y administración, telemetría, MQTT e infraestructura operativa.",
},
status: "verified",
sourceRefs: [
"docker-compose.yml",
"services/backend-api/src/main.rs",
"services/shared-lib/src/mqtt_messages.rs",
],
blocks: [
{
type: "paragraph",
text: {
en: "OmniOilPersonal is a Rust and TypeScript workspace. The production stack runs a Rust Axum backend, TimescaleDB/Postgres, Redis, MinIO, Mosquitto, a landing app, admin and field frontends, an events relay, telemetry ingestor, JSON report generator, and build orchestration services.",
es: "OmniOilPersonal es un workspace de Rust y TypeScript. El stack de producción ejecuta un backend Rust Axum, TimescaleDB/Postgres, Redis, MinIO, Mosquitto, una landing app, frontends de administración y campo, un relay de eventos, un ingestor de telemetría, un generador de reportes JSON y servicios de orquestación de builds.",
},
},
{
type: "table",
columns: { en: ["Layer", "Verified responsibility", "Evidence"], es: ["Capa", "Responsabilidad verificada", "Evidencia"] },
rows: {
en: [
["Backend API", "HTTP API, WebSocket telemetry tickets, MQTT relay, migrations, metrics", "services/backend-api/src/main.rs"],
["Field edge", "MQTT topics for status, health, logs, alarms, telemetry, provisioning", "services/backend-api/src/main.rs"],
["Storage", "TimescaleDB/Postgres for telemetry, operations, auth, MQTT ACLs; Redis and MinIO are compose services", "docker-compose.yml; services/backend-api/migrations"],
["Frontend apps", "Landing, admin dashboard, field PWA, and console are built as separate frontend targets", "docker-compose.yml"],
],
es: [
["Backend API", "HTTP API, tickets de telemetría por WebSocket, relay MQTT, migraciones y métricas", "services/backend-api/src/main.rs"],
["Field edge", "Tópicos MQTT para estado, salud, logs, alarmas, telemetría y aprovisionamiento", "services/backend-api/src/main.rs"],
["Almacenamiento", "TimescaleDB/Postgres para telemetría, operaciones, auth y ACLs MQTT; Redis y MinIO son servicios de compose", "docker-compose.yml; services/backend-api/migrations"],
["Apps frontend", "Landing, admin dashboard, Field PWA y consola se construyen como targets frontend separados", "docker-compose.yml"],
],
},
},
{
type: "callout",
tone: "verified",
title: { en: "Verified from repository sources", es: "Verificado desde fuentes del repositorio" },
body: {
en: "The service topology and MQTT topic handling are derived from compose and backend route/event-loop code, not from marketing copy.",
es: "La topología de servicios y el manejo de tópicos MQTT se derivan de compose y del código de rutas/event loop del backend, no de textos de marketing.",
},
},
],
pendingValidation: {
en: [
"Production deployment topology, uptime guarantees, and security certifications are intentionally not asserted here.",
"Exact external domain routing should be validated against the deployed Nginx/gateway configuration before publishing operator-facing runbooks.",
],
es: [
"La topología de despliegue productivo, las garantías de uptime y las certificaciones de seguridad no se afirman intencionalmente acá.",
"El ruteo exacto de dominios externos debe validarse contra la configuración Nginx/gateway desplegada antes de publicar runbooks para operadores.",
],
},
},
{
slug: "backend-api",
title: { en: "Backend API", es: "Backend API" },
description: {
en: "Route groups exposed by the Rust Axum backend, including auth, operations, ANH reports, edge management, telemetry, billing, and OpenAPI docs.",
es: "Grupos de rutas expuestos por el backend Rust Axum, incluyendo auth, operaciones, reportes ANH, gestión edge, telemetría, facturación y documentación OpenAPI.",
},
status: "verified",
sourceRefs: ["services/backend-api/src/main.rs", "services/backend-api/src/handlers/docs.rs"],
blocks: [
{
type: "paragraph",
text: {
en: "The backend binds on port 8000 and exposes health, Swagger UI, OpenAPI YAML, Prometheus metrics, REST API groups, and a WebSocket telemetry stream guarded by a short-lived ticket endpoint.",
es: "El backend escucha en el puerto 8000 y expone health, Swagger UI, OpenAPI YAML, métricas de Prometheus, grupos REST API y un stream de telemetría por WebSocket protegido por un endpoint de tickets de corta duración.",
},
},
{
type: "table",
columns: { en: ["Route group", "Verified surface"], es: ["Grupo de rutas", "Superficie verificada"] },
rows: {
en: [
["/docs and /docs/openapi.yaml", "Swagger UI and OpenAPI YAML from backend handlers"],
["/api/auth", "Login, refresh, logout, 2FA, registration, invitations, setup, request-access"],
["/api/admin and /api/platform", "User, role, access request, billing, platform operator, and 2FA administration surfaces"],
["/api/edge", "Projects, assets, devices, variables, deployments, agent logs/health, OTA, update info, OPC UA scan"],
["/api/telemetry and /api/ws/telemetry", "Telemetry ingestion/history and WebSocket telemetry streaming via one-time tickets"],
["/api/anh, /api/movements, /api/downtime, /api/well-tests, /api/lab-results, /api/meters/readings", "ANH reporting and operational data entry/query groups"],
],
es: [
["/docs and /docs/openapi.yaml", "Swagger UI y OpenAPI YAML desde handlers del backend"],
["/api/auth", "Login, refresh, logout, 2FA, registro, invitaciones, setup y request-access"],
["/api/admin and /api/platform", "Superficies de usuarios, roles, solicitudes de acceso, facturación, operadores de plataforma y administración 2FA"],
["/api/edge", "Proyectos, assets, dispositivos, variables, deployments, logs/salud del agente, OTA, update info y scan OPC UA"],
["/api/telemetry and /api/ws/telemetry", "Ingesta/historial de telemetría y streaming por WebSocket mediante tickets de un solo uso"],
["/api/anh, /api/movements, /api/downtime, /api/well-tests, /api/lab-results, /api/meters/readings", "Grupos de reporte ANH y carga/consulta de datos operativos"],
],
},
},
{
type: "callout",
tone: "pending",
title: { en: "OpenAPI publication note", es: "Nota de publicación OpenAPI" },
body: {
en: "The backend exposes /docs/openapi.yaml. This landing docs slice links conceptually to that backend asset but does not create a landing /docs/openapi.yaml proxy route.",
es: "El backend expone /docs/openapi.yaml. Esta sección de docs de la landing enlaza conceptualmente a ese asset del backend, pero no crea una ruta proxy /docs/openapi.yaml en la landing.",
},
},
],
pendingValidation: {
en: [
"Authentication and authorization details for each route group should be reviewed against handler extractors and middleware before writing public API examples.",
"Request/response schemas should be generated from the backend OpenAPI artifact rather than rewritten manually.",
],
es: [
"Los detalles de autenticación y autorización de cada grupo de rutas deben revisarse contra los extractors y middleware antes de escribir ejemplos públicos de API.",
"Los schemas de request/response deben generarse desde el artefacto OpenAPI del backend en lugar de reescribirse manualmente.",
],
},
},
{
slug: "database",
title: { en: "Database and migrations", es: "Base de datos y migraciones" },
description: {
en: "Postgres/TimescaleDB schema areas verified from SQL migrations and backend startup behavior.",
es: "Áreas del schema Postgres/TimescaleDB verificadas desde migraciones SQL y comportamiento de arranque del backend.",
},
status: "verified",
sourceRefs: [
"services/backend-api/migrations/20260408000000_initial_schema.sql",
"services/backend-api/src/main.rs",
],
blocks: [
{
type: "paragraph",
text: {
en: "The backend runs SQLx migrations at startup. The initial schema enables uuid-ossp and pgcrypto, creates roles/users, edge hierarchy tables, TimescaleDB telemetry hypertables, MQTT auth/ACL tables, provisioning tables, ANH operations/reporting tables, and alert/notification storage.",
es: "El backend ejecuta migraciones SQLx al arrancar. El schema inicial habilita uuid-ossp y pgcrypto, crea roles/usuarios, tablas de jerarquía edge, hypertables de telemetría en TimescaleDB, tablas MQTT auth/ACL, tablas de aprovisionamiento, tablas de operaciones/reporting ANH y almacenamiento de alertas/notificaciones.",
},
},
{
type: "list",
items: {
en: [
"telemetry_raw is a TimescaleDB hypertable with compression and a 31-day raw retention policy.",
"telemetry_5min is a continuous aggregate with a 5-year retention policy noted in the migration comments for ANH history.",
"agent_logs and telemetry_alarms are hypertables with compression/retention policies.",
"mqtt_users and mqtt_acls support Mosquitto go-auth backed by Postgres.",
],
es: [
"telemetry_raw es una hypertable de TimescaleDB con compresión y una política de retención raw de 31 días.",
"telemetry_5min es un agregado continuo con una política de retención de 5 años indicada en los comentarios de migración para historial ANH.",
"agent_logs y telemetry_alarms son hypertables con políticas de compresión/retención.",
"mqtt_users y mqtt_acls soportan Mosquitto go-auth respaldado por Postgres.",
],
},
},
{
type: "callout",
tone: "verified",
title: { en: "Migration source of truth", es: "Migraciones como fuente de verdad" },
body: {
en: "Table names and retention statements above come from SQL migrations. Application behavior can still further constrain how data is written or read.",
es: "Los nombres de tablas y las declaraciones de retención anteriores vienen de migraciones SQL. El comportamiento de la aplicación todavía puede restringir más cómo se escriben o leen los datos.",
},
},
],
pendingValidation: {
en: [
"Operational backup, restore, and disaster recovery procedures are not documented in this slice.",
"Storage sizing and compression savings must be validated with production-like datasets before publishing capacity guidance.",
],
es: [
"Los procedimientos operativos de backup, restore y disaster recovery no están documentados en esta sección.",
"El dimensionamiento de almacenamiento y los ahorros por compresión deben validarse con datasets similares a producción antes de publicar guías de capacidad.",
],
},
},
{
slug: "admin-dashboard",
title: { en: "Admin dashboard", es: "Admin dashboard" },
description: {
en: "Verified React routes and feature areas in the admin frontend.",
es: "Rutas React y áreas funcionales verificadas en el frontend de administración.",
},
status: "verified",
sourceRefs: ["services/frontend-admin/src/app/router/index.tsx", "services/frontend-admin/src/features/admin"],
blocks: [
{
type: "paragraph",
text: {
en: "The admin app is a React/Vite application using react-router. Protected admin routes live under /app/admin and include dashboards, realtime views, project management, alerts, ANH reports, users, settings, billing, and high-frequency tank pages.",
es: "La app admin es una aplicación React/Vite que usa react-router. Las rutas admin protegidas viven bajo /app/admin e incluyen dashboards, vistas realtime, gestión de proyectos, alertas, reportes ANH, usuarios, configuración, facturación y páginas de tanques de alta frecuencia.",
},
},
{
type: "table",
columns: { en: ["Route", "Verified feature"], es: ["Ruta", "Funcionalidad verificada"] },
rows: {
en: [
["/app/admin/dashboard", "Dashboard page"],
["/app/admin/realtime", "Realtime page"],
["/app/admin/projects and /projects/:id", "Project list, create, and details"],
["/app/admin/alerts", "Alerts page"],
["/app/admin/anh-report", "ANH report page"],
["/app/admin/users, /settings, /billing", "Administrative user/settings/billing surfaces"],
["/app/admin/hf/tanks", "Tank inventory and detail pages"],
],
es: [
["/app/admin/dashboard", "Página de dashboard"],
["/app/admin/realtime", "Página realtime"],
["/app/admin/projects and /projects/:id", "Listado, creación y detalle de proyectos"],
["/app/admin/alerts", "Página de alertas"],
["/app/admin/anh-report", "Página de reporte ANH"],
["/app/admin/users, /settings, /billing", "Superficies administrativas de usuarios/configuración/facturación"],
["/app/admin/hf/tanks", "Inventario de tanques y páginas de detalle"],
],
},
},
],
pendingValidation: {
en: [
"Role-specific menu visibility and permissions should be validated against ProtectedRoute, auth state, and API authorization before documenting operator playbooks.",
"Screenshots and workflow steps are intentionally omitted until UI flows are reviewed end-to-end.",
],
es: [
"La visibilidad del menú y los permisos por rol deben validarse contra ProtectedRoute, el estado de auth y la autorización de API antes de documentar playbooks para operadores.",
"Las capturas y pasos de workflow se omiten intencionalmente hasta que los flujos UI se revisen end-to-end.",
],
},
},
{
slug: "field-pwa",
title: { en: "Field PWA", es: "Field PWA" },
description: {
en: "Verified field/mobile routes and offline sync primitives in the PWA frontend.",
es: "Rutas field/mobile y primitivas de sincronización offline verificadas en el frontend PWA.",
},
status: "verified",
sourceRefs: [
"services/frontend-pwa/src/app/router/index.tsx",
"services/frontend-pwa/src/lib/sync/sync-store.ts",
"services/frontend-pwa/src/lib/db/index.ts",
],
blocks: [
{
type: "paragraph",
text: {
en: "The field PWA exposes protected operator routes under /app/field. Implemented route entries include measurements, movements, sync, and placeholders for well shutdowns and well tests. Offline state is tracked with a sync queue store that counts pending, syncing, rejected, and synced items.",
es: "La Field PWA expone rutas protegidas de operador bajo /app/field. Las entradas de ruta implementadas incluyen mediciones, movimientos, sync y placeholders para cierres de pozo y pruebas de pozo. El estado offline se rastrea con un store de cola de sincronización que cuenta ítems pendientes, sincronizando, rechazados y sincronizados.",
},
},
{
type: "list",
items: {
en: [
"The PWA redirects legacy /app/admin/* traffic toward the admin surface for active admin sessions.",
"The sync store can add queue items, retry rejected items, refresh pending/error counts, and request a sync run.",
"The offline route is explicitly registered at /offline.",
],
es: [
"La PWA redirige tráfico legacy /app/admin/* hacia la superficie admin para sesiones admin activas.",
"El sync store puede agregar ítems a la cola, reintentar ítems rechazados, refrescar conteos pending/error y solicitar una ejecución de sync.",
"La ruta offline está registrada explícitamente en /offline.",
],
},
},
],
pendingValidation: {
en: [
"Browser service-worker registration and background sync behavior should be validated in a real PWA install context.",
"Conflict resolution semantics for queued records need workflow-level review before being documented as guarantees.",
],
es: [
"El registro del service worker del navegador y el comportamiento de background sync deben validarse en un contexto real de instalación PWA.",
"La semántica de resolución de conflictos para registros en cola necesita revisión a nivel de workflow antes de documentarse como garantía.",
],
},
},
{
slug: "infrastructure",
title: { en: "Infrastructure and local stack", es: "Infraestructura y stack local" },
description: {
en: "Docker Compose services, local development ports, and infrastructure files verified from repository configuration.",
es: "Servicios Docker Compose, puertos de desarrollo local y archivos de infraestructura verificados desde la configuración del repositorio.",
},
status: "verified",
sourceRefs: [
"docker-compose.yml",
"docker-compose.dev.yml",
"infrastructure/nginx/gateway.conf",
"infrastructure/mosquitto/mosquitto.conf.template",
],
blocks: [
{
type: "paragraph",
text: {
en: "The base compose stack defines TimescaleDB, Redis, MinIO, backend-api, events-relay, telemetry-ingestor, json-generator, landing, frontend-pwa, frontend-admin, frontend-console, Mosquitto, build orchestration, Prometheus, and Grafana services on the shared anh_network.",
es: "El stack base de compose define servicios TimescaleDB, Redis, MinIO, backend-api, events-relay, telemetry-ingestor, json-generator, landing, frontend-pwa, frontend-admin, frontend-console, Mosquitto, orquestación de builds, Prometheus y Grafana sobre la red compartida anh_network.",
},
},
{
type: "code",
language: "bash",
code: "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d\ndocker compose build",
},
{
type: "table",
columns: { en: ["Development port", "Service"], es: ["Puerto de desarrollo", "Servicio"] },
rows: {
en: [
["3004", "landing"],
["3000", "frontend-admin"],
["3001", "frontend-pwa"],
["3002", "frontend-console"],
["8000", "backend-api"],
["1883 / 9883", "Mosquitto"],
["9090 / 3003", "Prometheus / Grafana"],
],
es: [
["3004", "landing"],
["3000", "frontend-admin"],
["3001", "frontend-pwa"],
["3002", "frontend-console"],
["8000", "backend-api"],
["1883 / 9883", "Mosquitto"],
["9090 / 3003", "Prometheus / Grafana"],
],
},
},
],
pendingValidation: {
en: [
"Secrets, TLS, public exposure, and production port mappings must be validated against deployment-specific environment and gateway configuration.",
"Monitoring dashboards exist in infrastructure/monitoring, but alerting policy is not validated by this docs slice.",
],
es: [
"Secrets, TLS, exposición pública y mapeos de puertos productivos deben validarse contra el environment y la configuración de gateway específicos del despliegue.",
"Existen dashboards de monitoreo en infrastructure/monitoring, pero la política de alertas no está validada por esta sección de docs.",
],
},
},
{
slug: "simulators",
title: { en: "Simulators and test utilities", es: "Simuladores y utilidades de prueba" },
description: {
en: "Verified Modbus simulator behavior and supporting infrastructure tests.",
es: "Comportamiento verificado del simulador Modbus y pruebas de infraestructura de soporte.",
},
status: "verified",
sourceRefs: [
"tests/simulators/unified_simulator.py",
"tests/simulators/test_unified_simulator_contract.py",
"infrastructure/tests/test_mqtt_auth.py",
],
blocks: [
{
type: "paragraph",
text: {
en: "The unified simulator generates approximately 1000 petroleum variables across 5 wells, writes a CSV matching the backend Modbus TCP import contract, serves an HTTP dashboard, and can run a Modbus TCP server when the supported pymodbus version is installed.",
es: "El simulador unificado genera aproximadamente 1000 variables petroleras en 5 pozos, escribe un CSV compatible con el contrato de importación Modbus TCP del backend, sirve un dashboard HTTP y puede ejecutar un servidor Modbus TCP cuando está instalada la versión soportada de pymodbus.",
},
},
{
type: "table",
columns: { en: ["Contract", "Verified value"], es: ["Contrato", "Valor verificado"] },
rows: {
en: [
["Modbus port", "5020"],
["HTTP dashboard port", "8085"],
["Supported pymodbus version", "3.6.9"],
["Generated rows", "1000 variables across UnitId 1..5"],
],
es: [
["Puerto Modbus", "5020"],
["Puerto del dashboard HTTP", "8085"],
["Versión soportada de pymodbus", "3.6.9"],
["Filas generadas", "1000 variables entre UnitId 1..5"],
],
},
},
{
type: "callout",
tone: "pending",
title: { en: "Runtime dependency guard", es: "Guard de dependencia en runtime" },
body: {
en: "The simulator intentionally disables live Modbus behavior unless pymodbus==3.6.9 is present because newer APIs changed live register update behavior.",
es: "El simulador deshabilita intencionalmente el comportamiento Modbus live salvo que pymodbus==3.6.9 esté presente, porque APIs más nuevas cambiaron el comportamiento de actualización live de registros.",
},
},
],
pendingValidation: {
en: [
"End-to-end ingestion from simulator CSV through backend import and agent deployment should be validated separately.",
"MQTT auth tests exist under infrastructure/tests, but production broker policy should be checked against deployed go-auth configuration.",
],
es: [
"La ingesta end-to-end desde el CSV del simulador hasta la importación del backend y el deployment del agente debe validarse por separado.",
"Existen pruebas de auth MQTT bajo infrastructure/tests, pero la política del broker productivo debe revisarse contra la configuración go-auth desplegada.",
],
},
},
];
const localizedDocsOverview = {
title: {
en: "OmniOilPersonal documentation",
es: "Documentación de OmniOilPersonal",
},
description: {
en: "Code-derived technical documentation for the OmniOilPersonal repository. Claims are either backed by repository sources or marked as pending validation.",
es: "Documentación técnica derivada del código para el repositorio OmniOilPersonal. Las afirmaciones están respaldadas por fuentes del repositorio o marcadas como pendientes de validación.",
},
} as const;
function localizeBlock(block: LocalizedDocsBlock, lang: Language): DocsBlock {
if (block.type === "paragraph") {
return { type: "paragraph", text: block.text[lang] };
}
if (block.type === "callout") {
return { type: "callout", tone: block.tone, title: block.title[lang], body: block.body[lang] };
}
if (block.type === "list") {
return { type: "list", items: block.items[lang] };
}
if (block.type === "table") {
return { type: "table", columns: block.columns[lang], rows: block.rows[lang] };
}
return block;
}
function localizeTopic(topic: LocalizedDocsTopic, lang: Language): DocsTopic {
return {
slug: topic.slug,
title: topic.title[lang],
description: topic.description[lang],
status: topic.status,
sourceRefs: topic.sourceRefs,
blocks: topic.blocks.map((block) => localizeBlock(block, lang)),
pendingValidation: topic.pendingValidation[lang],
};
}
export function getDocsOverview(lang: Language) {
return {
title: localizedDocsOverview.title[lang],
description: localizedDocsOverview.description[lang],
};
}
export function getDocsTopics(lang: Language): DocsTopic[] {
return localizedDocsTopics.map((topic) => localizeTopic(topic, lang));
}
export function getDocsTopic(slug: string, lang: Language = "en"): DocsTopic | undefined {
const topic = localizedDocsTopics.find((item) => item.slug === slug);
return topic ? localizeTopic(topic, lang) : undefined;
}
export const docsTopics = getDocsTopics("en");
export const docsOverview = getDocsOverview("en");
export const docsRoutes = localizedDocsTopics.map((topic) => `/docs/${topic.slug}` as const);

View File

@@ -12,6 +12,7 @@ export const t: Translations = {
"nav.architecture": { en: "Platform", es: "Plataforma" }, "nav.architecture": { en: "Platform", es: "Plataforma" },
"nav.pricing": { en: "Pricing", es: "Precios" }, "nav.pricing": { en: "Pricing", es: "Precios" },
"nav.compliance": { en: "Compliance", es: "Cumplimiento" }, "nav.compliance": { en: "Compliance", es: "Cumplimiento" },
"nav.docs": { en: "Docs", es: "Documentación" },
"nav.signIn": { en: "Sign In", es: "Iniciar Sesión" }, "nav.signIn": { en: "Sign In", es: "Iniciar Sesión" },
"nav.startTrial": { en: "Request Access", es: "Solicitar Acceso" }, "nav.startTrial": { en: "Request Access", es: "Solicitar Acceso" },
"nav.openMenu": { en: "Open navigation menu", es: "Abrir menú de navegación" }, "nav.openMenu": { en: "Open navigation menu", es: "Abrir menú de navegación" },

View File

@@ -33,6 +33,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client_id = format!("telemetry-ingestor-{}", Uuid::new_v4()); let client_id = format!("telemetry-ingestor-{}", Uuid::new_v4());
let mut mqttoptions = MqttOptions::new(client_id, mqtt_host, mqtt_port); let mut mqttoptions = MqttOptions::new(client_id, mqtt_host, mqtt_port);
mqttoptions.set_keep_alive(Duration::from_secs(5)); mqttoptions.set_keep_alive(Duration::from_secs(5));
// The ingestor subscribes to `omnioil/#`, so it can receive retained deploy
// messages in addition to telemetry. Large local project configs are
// compressed before deploy, but still exceed rumqttc's small default limit.
mqttoptions.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
if !mqtt_user.is_empty() { if !mqtt_user.is_empty() {
mqttoptions.set_credentials(mqtt_user, mqtt_pass); mqttoptions.set_credentials(mqtt_user, mqtt_pass);
} }

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