Compare commits
258 Commits
chore/repo
...
f38ca951f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f38ca951f7 | ||
|
|
4ccc1bbf90 | ||
|
|
e661caa514 | ||
|
|
d9daa6e6e4 | ||
|
|
5b68e2c80e | ||
|
|
5b58da0f18 | ||
|
|
70d1922906 | ||
|
|
d97fc76f16 | ||
|
|
eaaf368053 | ||
|
|
40cd3ece33 | ||
|
|
333c3b3b21 | ||
|
|
5cd46f75a0 | ||
|
|
f250ad11de | ||
| c8a8089243 | |||
|
|
565b0c0a61 | ||
| 9b806d3fec | |||
|
|
64c42a02e4 | ||
|
|
338967b949 | ||
|
|
b331b05d55 | ||
|
|
6dd7754206 | ||
| 30a16e5b6c | |||
| e229f0ae50 | |||
| 47d31837e9 | |||
| 04ef63537c | |||
|
|
ce9ad1f8f0 | ||
|
|
d9c01647ef | ||
|
|
e615b06f7b | ||
|
|
9559eb4e7d | ||
|
|
311a5422a9 | ||
|
|
132be581f5 | ||
|
|
c43642218e | ||
|
|
91181431aa | ||
| ac411b1893 | |||
|
|
d70efd6b5e | ||
| 49431911fc | |||
|
|
f28cb4dad7 | ||
| baa906001e | |||
|
|
f9151dd9ba | ||
|
|
f009c43bd2 | ||
|
|
113be509ee | ||
|
|
439b80dca8 | ||
| 51e27e49f4 | |||
|
|
948b372f40 | ||
|
|
3c6cc42216 | ||
|
|
675b3033a4 | ||
|
|
b5357a3d0c | ||
| 761a4ab5a4 | |||
|
|
89907fbb58 | ||
|
|
3dfe4c5659 | ||
|
|
741034ac39 | ||
|
|
8cfd7a085b | ||
|
|
88dc61512f | ||
|
|
18d8ffa375 | ||
|
|
c1254566c1 | ||
| d5c768d9c3 | |||
| b7f1873504 | |||
|
|
fac9ab3fe0 | ||
|
|
3417d8afaf | ||
|
|
e6f5412ca2 | ||
|
|
29e89a0a41 | ||
|
|
3caf9ba42b | ||
|
|
3721fa7d12 | ||
|
|
d486c07f87 | ||
|
|
e6e091895e | ||
|
|
9ee1e11608 | ||
|
|
4125ec627b | ||
|
|
0d6c006297 | ||
|
|
6c234f60fd | ||
|
|
3f850db0ae | ||
|
|
14b0a03b14 | ||
|
|
f18bfe65c3 | ||
|
|
9fdf049ea8 | ||
|
|
13562d3298 | ||
|
|
c71c58c5aa | ||
|
|
9f13d4f274 | ||
|
|
9e65a0b447 | ||
|
|
edcd02e469 | ||
|
|
88fb707a66 | ||
|
|
ea035b4b60 | ||
|
|
be3fcf1f28 | ||
|
|
a97f968be2 | ||
|
|
0ada878809 | ||
|
|
fd4220c98d | ||
|
|
0329296ea5 | ||
|
|
bfda385620 | ||
|
|
690f7ff48d | ||
|
|
e7c142f4cb | ||
|
|
5734abe176 | ||
|
|
6e755e2b56 | ||
|
|
4c7e092ffa | ||
|
|
095ec2ff25 | ||
|
|
66229ab050 | ||
|
|
83e067b334 | ||
|
|
c12deda9f9 | ||
|
|
c45862f49d | ||
|
|
eec83ea63d | ||
|
|
5ce407d5b1 | ||
|
|
e1f8d968a8 | ||
|
|
940f58ecdd | ||
|
|
4126ea9aa0 | ||
|
|
b7c348b655 | ||
|
|
509c91321f | ||
|
|
2928da900f | ||
|
|
0698ca2471 | ||
|
|
686704b658 | ||
|
|
a777a99367 | ||
|
|
13aea702f3 | ||
|
|
b43c41d2ae | ||
|
|
0e43805154 | ||
|
|
2ecca522cf | ||
|
|
37780092e3 | ||
|
|
7e6993fc72 | ||
|
|
8035f5005e | ||
|
|
7fb03039bd | ||
|
|
b90b91f7db | ||
|
|
fb0764bf9a | ||
|
|
86c85c4110 | ||
|
|
8b572457e0 | ||
|
|
bd473719bb | ||
|
|
b4dcae280a | ||
|
|
0c510e120b | ||
|
|
92675d9616 | ||
|
|
932932e9e6 | ||
|
|
c9d7f82b2d | ||
|
|
5e385667e8 | ||
|
|
1652669aad | ||
|
|
10fce1119f | ||
|
|
1d1b13b478 | ||
|
|
d7dc59b7f3 | ||
|
|
9cfddf0be6 | ||
|
|
4f91382b5e | ||
|
|
e29210b16c | ||
|
|
b8ab916c8d | ||
|
|
d2a3143c85 | ||
|
|
473631d329 | ||
|
|
70a2282716 | ||
|
|
fe0176da4a | ||
|
|
2bc9eb3120 | ||
|
|
0bb57a0749 | ||
|
|
3ca1d31ede | ||
|
|
3be117cb29 | ||
|
|
2da824f1a6 | ||
|
|
89590121a6 | ||
|
|
83f4c54d2c | ||
|
|
e45b05d288 | ||
|
|
e6a9a40ff8 | ||
|
|
14aa7c252a | ||
|
|
2e42230f54 | ||
|
|
4217e8ec39 | ||
|
|
9cdafcd262 | ||
|
|
1c08e44f45 | ||
|
|
08e097d640 | ||
|
|
330fb7cb30 | ||
|
|
5301f72e28 | ||
|
|
06c81822d0 | ||
|
|
02f24f27ce | ||
|
|
28d1a7d930 | ||
|
|
1c57abd97a | ||
|
|
fb0c172bb5 | ||
|
|
6f486a038b | ||
|
|
78e8ecb55a | ||
|
|
9f3d7212ee | ||
|
|
c81ed907f7 | ||
|
|
f90fb552fc | ||
|
|
83988cfcde | ||
|
|
22b39aadd3 | ||
|
|
9c878a310b | ||
|
|
2d9ef90b09 | ||
|
|
e6d884ae95 | ||
|
|
2dd1975b8d | ||
|
|
fa1be83665 | ||
|
|
f19a13def1 | ||
|
|
2c3dd8e8bd | ||
|
|
47d6a4e48d | ||
|
|
7ccadb0272 | ||
|
|
154f183341 | ||
| d1647ddbcf | |||
|
|
d130ee578f | ||
|
|
6afacdc7ce | ||
|
|
4566a57e94 | ||
|
|
79dd41ba6f | ||
|
|
77bcde08ef | ||
|
|
56bb76fdb2 | ||
|
|
7a774a70fc | ||
|
|
2a53ae7e9e | ||
|
|
c97ab61512 | ||
|
|
d6bbc66676 | ||
|
|
997b6b0da8 | ||
|
|
555aef226e | ||
|
|
f9af6a552e | ||
|
|
60ad7ca44f | ||
|
|
de213a22f0 | ||
|
|
93307c3b8f | ||
|
|
edc773b367 | ||
|
|
f43a3f202d | ||
|
|
a379c8dd59 | ||
|
|
02151e56dd | ||
|
|
db259b5dbe | ||
|
|
8359ae9a06 | ||
|
|
2430419257 | ||
|
|
ba0b0c633f | ||
|
|
7d2013cedf | ||
|
|
660092a8ff | ||
|
|
0092d20e35 | ||
|
|
1c9059a0ea | ||
|
|
a0cb973078 | ||
|
|
c92e0b5c06 | ||
|
|
0949f19c9d | ||
|
|
3bde9096ab | ||
| 63ef77d1ce | |||
| dae5e8bf00 | |||
|
|
a524e52a19 | ||
|
|
6f780cf22e | ||
|
|
0ad8fc05c1 | ||
|
|
977e500bd6 | ||
|
|
7efef41179 | ||
|
|
accefcd375 | ||
|
|
f83d530c18 | ||
|
|
58de3dc1f9 | ||
|
|
80403eeb20 | ||
|
|
dcf72cef79 | ||
|
|
58ebb1adf0 | ||
|
|
e56dac1269 | ||
|
|
98a09289a6 | ||
|
|
b34213c9c8 | ||
|
|
6e9fdc0f17 | ||
| a36b06bbf8 | |||
| 84f5c42e5b | |||
| 4f1f530ce1 | |||
|
|
dd4511cdfc | ||
|
|
55a3c9895d | ||
|
|
ee0ed8800a | ||
|
|
801c78fd57 | ||
|
|
9e5b69f3a2 | ||
|
|
11f4757fe0 | ||
|
|
980fbcc3b3 | ||
|
|
f3fd71e286 | ||
|
|
f0ce0a67cc | ||
| 7aec3b67fc | |||
| 0551d41475 | |||
| dade0608a8 | |||
| ebb165bbff | |||
|
|
6d0c432043 | ||
|
|
c72868f65b | ||
|
|
39eea9d6a4 | ||
|
|
d00db12ec0 | ||
| e83d88c599 | |||
|
|
e26af8ef37 | ||
|
|
0b73ef5c49 | ||
|
|
f5ba0df791 | ||
|
|
12c3564e01 | ||
|
|
a4f1fa137d | ||
|
|
952514784e | ||
|
|
ab000b29a9 | ||
|
|
487b075493 | ||
|
|
671a5339a9 | ||
| f910768719 | |||
| 30eff47bec |
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
0
infrastructure/mosquitto/entrypoint.sh
Executable file → Normal file
@@ -44,6 +44,9 @@ persistence true
|
|||||||
persistence_location /mosquitto/data
|
persistence_location /mosquitto/data
|
||||||
|
|
||||||
# Límite de tamaño de paquetes MQTT
|
# Límite de tamaño de paquetes MQTT
|
||||||
# 256KB: suficiente para payloads de provisioning comprimidos y telemetría
|
# 2MB: suficiente para ProjectConfig sin comprimir durante deploys locales/grandes
|
||||||
# (rumqttc también está configurado con 256KB)
|
# y payloads de telemetría de simulador con cientos de variables por dispositivo.
|
||||||
message_size_limit 262144
|
# Mosquitto 2.x anuncia `max_packet_size` a clientes MQTT v5; mantener ambos
|
||||||
|
# evita que clientes rumqttc sigan viendo el límite default de 256KB.
|
||||||
|
max_packet_size 2097152
|
||||||
|
message_size_limit 2097152
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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: {}",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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) && \
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -139,8 +139,9 @@ pub async fn setup_mqtt_client(
|
|||||||
|
|
||||||
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
||||||
mqtt_options.set_clean_session(true);
|
mqtt_options.set_clean_session(true);
|
||||||
// 256KB: suficiente para cualquier mensaje de telemetría comprimida
|
// 2MB: supports large deploy signals and high-cardinality telemetry payloads
|
||||||
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024);
|
// during local simulator runs with hundreds of variables per device.
|
||||||
|
mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
|
||||||
|
|
||||||
// Last Will Testament: si el agente se desconecta inesperadamente,
|
// Last Will Testament: si el agente se desconecta inesperadamente,
|
||||||
// el broker publica automáticamente el estado "offline" de forma inmediata.
|
// el broker publica automáticamente el estado "offline" de forma inmediata.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
|
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
|
||||||
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
|
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
|
||||||
//! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre.
|
//! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre.
|
||||||
|
//!
|
||||||
|
//! ⚠️ IMPORTANTE: Usa `tokio::sync::Mutex` (NO `std::sync::Mutex`) para evitar
|
||||||
|
//! deadlocks en el runtime async de Tokio. Si se usa un mutex síncrono dentro de
|
||||||
|
//! un contexto async, el executor se congela al intentar adquirir un lock que otro
|
||||||
|
//! task del mismo hilo ya tiene.
|
||||||
|
|
||||||
use aes_gcm::{
|
use aes_gcm::{
|
||||||
Aes256Gcm, Nonce,
|
Aes256Gcm, Nonce,
|
||||||
@@ -13,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],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -6,11 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
||||||
|
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
|
||||||
|
|
||||||
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
||||||
getAlarms: vi.fn(async () => []),
|
getAlarms: vi.fn(async () => []),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
||||||
|
listProjects: vi.fn(async () => []),
|
||||||
|
getProjectFullConfig: vi.fn(async () => ({ project: { id: 'project-1', name: 'Bloque Norte' }, assets: [] })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function flushQueries() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resetAuthStore() {
|
function resetAuthStore() {
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
||||||
useAuthStore.persist.clearStorage()
|
useAuthStore.persist.clearStorage()
|
||||||
@@ -42,6 +57,14 @@ describe('AdminLayout visual shell', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
resetAuthStore()
|
resetAuthStore()
|
||||||
seedAdminSession()
|
seedAdminSession()
|
||||||
|
vi.mocked(listProjects).mockResolvedValue([
|
||||||
|
{ id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
{ id: 'project-2', name: 'Bloque Sur', client: 'Cliente', operator_name: 'Operador', contract_number: '002', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
])
|
||||||
|
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
||||||
|
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
assets: [],
|
||||||
|
})
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
container = document.createElement('div')
|
container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
@@ -156,6 +179,32 @@ describe('AdminLayout visual shell', () => {
|
|||||||
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('matches query-specific realtime navigation items by search params', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/realtime?view=trends']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/*" element={<AdminLayout />}>
|
||||||
|
<Route path="realtime" element={<div>Realtime operativo</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const topbar = container?.querySelector('[aria-label="Barra superior del dashboard"]')
|
||||||
|
const variablesLink = container?.querySelector<HTMLAnchorElement>('a[href="/app/admin/realtime?view=variables"]')
|
||||||
|
const trendsLink = container?.querySelector<HTMLAnchorElement>('a[href="/app/admin/realtime?view=trends"]')
|
||||||
|
|
||||||
|
expect(topbar?.textContent).toContain('Tendencias')
|
||||||
|
expect(variablesLink?.getAttribute('aria-current')).toBeNull()
|
||||||
|
expect(trendsLink?.getAttribute('aria-current')).toBe('page')
|
||||||
|
expect(variablesLink?.className).not.toContain('font-semibold')
|
||||||
|
expect(trendsLink?.className).toContain('font-semibold')
|
||||||
|
})
|
||||||
|
|
||||||
it('exposes a desktop sidebar toggle before the current location', async () => {
|
it('exposes a desktop sidebar toggle before the current location', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(
|
root?.render(
|
||||||
@@ -212,6 +261,79 @@ describe('AdminLayout visual shell', () => {
|
|||||||
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders real project wells under the Pozos sidebar group with active alarm badges', async () => {
|
||||||
|
vi.mocked(getAlarms).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'alarm-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
asset_id: 'well-1',
|
||||||
|
variable_alias: 'presion',
|
||||||
|
alarm_type: 'ABOVE_MAX',
|
||||||
|
acknowledged: false,
|
||||||
|
timestamp: '2026-06-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
||||||
|
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
asset: {
|
||||||
|
id: 'well-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
code: 'PZ-01',
|
||||||
|
name: 'Pozo Norte',
|
||||||
|
asset_type: 'POZO',
|
||||||
|
is_active: true,
|
||||||
|
metadata: {},
|
||||||
|
is_collector_enabled: true,
|
||||||
|
created_at: '2026-06-22T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
devices: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asset: {
|
||||||
|
id: 'tank-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
code: 'TK-01',
|
||||||
|
name: 'Tanque Norte',
|
||||||
|
asset_type: 'TANQUE',
|
||||||
|
is_active: true,
|
||||||
|
metadata: {},
|
||||||
|
is_collector_enabled: true,
|
||||||
|
created_at: '2026-06-22T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
devices: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/*" element={<AdminLayout />}>
|
||||||
|
<Route path="wells/:wellId" element={<div>Dashboard del pozo</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await flushQueries()
|
||||||
|
|
||||||
|
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(container?.textContent).toContain('Pozo Norte')
|
||||||
|
})
|
||||||
|
expect(container?.textContent).toContain('Pozos')
|
||||||
|
expect(container?.textContent).not.toContain('Tanque Norte')
|
||||||
|
expect(container?.querySelector('a[href="/app/admin/wells/well-1"]')?.className).toContain('bg-[#24282e]')
|
||||||
|
expect(container?.textContent).toContain('1')
|
||||||
|
})
|
||||||
|
|
||||||
it('restores the persisted collapsed desktop preference on render', async () => {
|
it('restores the persisted collapsed desktop preference on render', async () => {
|
||||||
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')
|
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, NavLink, Link, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
@@ -12,13 +12,18 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Activity,
|
Activity,
|
||||||
|
Table2,
|
||||||
|
LineChart,
|
||||||
Users,
|
Users,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
CreditCard
|
CreditCard,
|
||||||
|
ChevronDown,
|
||||||
|
Gauge,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { cn } from '@/lib/utils/cn'
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
||||||
|
|
||||||
@@ -26,6 +31,19 @@ function isPlatformGlobalRole(role?: string | null) {
|
|||||||
return role === 'platform_admin' || role === 'platform_operator'
|
return role === 'platform_admin' || role === 'platform_operator'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNavItemActive(to: string, location: Location) {
|
||||||
|
const [path, query] = to.split('?')
|
||||||
|
const pathMatches = location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||||
|
|
||||||
|
if (!pathMatches) return false
|
||||||
|
if (!query) return true
|
||||||
|
|
||||||
|
const expectedParams = new URLSearchParams(query)
|
||||||
|
const currentParams = new URLSearchParams(location.search)
|
||||||
|
|
||||||
|
return Array.from(expectedParams.entries()).every(([key, value]) => currentParams.get(key) === value)
|
||||||
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
@@ -50,7 +68,9 @@ export const navSections: NavSection[] = [
|
|||||||
label: 'Operaciones',
|
label: 'Operaciones',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] },
|
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] },
|
||||||
{ to: '/app/admin/realtime', label: 'Telemetria', icon: Activity, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/realtime?view=variables', label: 'Variables', icon: Table2, roles: ['admin', 'superadmin'] },
|
||||||
|
{ to: '/app/admin/realtime?view=trends', label: 'Tendencias', icon: LineChart, roles: ['admin', 'superadmin'] },
|
||||||
|
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,7 +78,6 @@ export const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] },
|
||||||
{ to: '/app/admin/settings', label: 'Ajustes', icon: Settings, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/settings', label: 'Ajustes', icon: Settings, roles: ['admin', 'superadmin'] },
|
||||||
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, roles: ['admin', 'superadmin'] },
|
|
||||||
{ to: '/app/admin/billing', label: 'Facturacion', icon: CreditCard, roles: ['admin', 'superadmin'] },
|
{ to: '/app/admin/billing', label: 'Facturacion', icon: CreditCard, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -76,6 +95,8 @@ export default function AdminLayout() {
|
|||||||
const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(() => {
|
const [isDesktopSidebarCollapsed, setIsDesktopSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
||||||
})
|
})
|
||||||
|
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
|
||||||
|
const [isWellsNavOpen, setIsWellsNavOpen] = useState(true)
|
||||||
|
|
||||||
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
||||||
|
|
||||||
@@ -85,7 +106,25 @@ export default function AdminLayout() {
|
|||||||
refetchInterval: 60000,
|
refetchInterval: 60000,
|
||||||
enabled: canFetchAlarms,
|
enabled: canFetchAlarms,
|
||||||
})
|
})
|
||||||
|
const sidebarProjectsQuery = useQuery({
|
||||||
|
queryKey: ['admin-sidebar-projects'],
|
||||||
|
queryFn: listProjects,
|
||||||
|
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
|
||||||
|
staleTime: 60000,
|
||||||
|
})
|
||||||
|
const sidebarProjects = sidebarProjectsQuery.data ?? []
|
||||||
|
const sidebarProjectId = activeProjectId ?? (sidebarProjects.length === 1 ? sidebarProjects[0]?.id : null)
|
||||||
|
const sidebarProjectConfigQuery = useQuery({
|
||||||
|
queryKey: ['admin-sidebar-project-config', sidebarProjectId],
|
||||||
|
queryFn: () => getProjectFullConfig(sidebarProjectId!),
|
||||||
|
enabled: !!accessToken && !!sidebarProjectId && ['admin', 'superadmin', 'auditor'].includes(user?.role || ''),
|
||||||
|
staleTime: 60000,
|
||||||
|
})
|
||||||
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
|
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
|
||||||
|
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
|
||||||
|
const sidebarWells = sidebarProjectConfigQuery.data?.assets
|
||||||
|
.map(({ asset }) => asset)
|
||||||
|
.filter((asset) => asset.asset_type === 'POZO') ?? []
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -110,17 +149,24 @@ export default function AdminLayout() {
|
|||||||
})).filter(section => section.items.length > 0)
|
})).filter(section => section.items.length > 0)
|
||||||
const activeNavItem = filteredSections
|
const activeNavItem = filteredSections
|
||||||
.flatMap((section) => section.items)
|
.flatMap((section) => section.items)
|
||||||
.find((item) => location.pathname === item.to || location.pathname.startsWith(`${item.to}/`))
|
.find((item) => isNavItemActive(item.to, location))
|
||||||
const currentSectionLabel = activeNavItem?.label ?? 'Administración'
|
const isWellRouteActive = location.pathname.startsWith('/app/admin/wells/')
|
||||||
|
const currentSectionLabel = activeNavItem?.label ?? (isWellRouteActive ? 'Pozos' : 'Administración')
|
||||||
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
|
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
|
||||||
const alertsButtonLabel = unacknowledgedCount > 0
|
const alertsButtonLabel = unacknowledgedCount > 0
|
||||||
? `Ver ${unacknowledgedCount} alertas pendientes`
|
? `Ver ${unacknowledgedCount} alertas pendientes`
|
||||||
: 'Ver alertas'
|
: 'Ver alertas'
|
||||||
|
const getProjectAlarmCount = (projectId: string) => {
|
||||||
|
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
|
||||||
|
}
|
||||||
|
const getWellAlarmCount = (wellId: string) => {
|
||||||
|
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === wellId).length
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
<div className="flex h-screen overflow-hidden bg-[#090b0f] text-foreground">
|
||||||
{/* Mobile top bar */}
|
{/* Mobile top bar */}
|
||||||
<header className="md:hidden fixed top-0 w-full h-14 px-4 flex items-center justify-between border-b border-white/10 bg-card/95 backdrop-blur-md z-30">
|
<header className="md:hidden fixed top-0 w-full h-14 px-4 flex items-center justify-between border-b border-[#232831] bg-[#0b0f14]/95 backdrop-blur-md z-30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src="/omnioil-logo-transparente.svg"
|
src="/omnioil-logo-transparente.svg"
|
||||||
@@ -160,15 +206,15 @@ export default function AdminLayout() {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 md:z-20 flex w-56 shrink-0 flex-col",
|
"fixed inset-y-0 left-0 z-50 md:z-20 flex w-[216px] shrink-0 flex-col",
|
||||||
"border-r border-white/10 bg-card/95 backdrop-blur-md md:relative md:translate-x-0 transition-all duration-300 ease-out",
|
"border-r border-[#232831] bg-[#0b0f14] md:relative md:translate-x-0 transition-all duration-300 ease-out",
|
||||||
isDesktopSidebarCollapsed ? "md:w-20" : "md:w-56",
|
isDesktopSidebarCollapsed ? "md:w-20" : "md:w-[216px]",
|
||||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex h-20 shrink-0 items-center justify-between border-b border-white/10 px-4",
|
"flex h-20 shrink-0 items-center justify-between border-b border-[#232831] px-4",
|
||||||
isDesktopSidebarCollapsed && "md:justify-center md:px-3"
|
isDesktopSidebarCollapsed && "md:justify-center md:px-3"
|
||||||
)}>
|
)}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -192,7 +238,7 @@ export default function AdminLayout() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"pl-0.5 text-[9px] font-semibold text-primary/75 uppercase tracking-[0.22em] transition-opacity duration-200",
|
"pl-0.5 text-[9px] font-semibold text-primary uppercase tracking-[0.28em] transition-opacity duration-200",
|
||||||
isDesktopSidebarCollapsed && "md:sr-only"
|
isDesktopSidebarCollapsed && "md:sr-only"
|
||||||
)}>Operación centralizada</p>
|
)}>Operación centralizada</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,66 +253,205 @@ export default function AdminLayout() {
|
|||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className={cn(
|
<nav className={cn(
|
||||||
"flex-1 overflow-y-auto custom-sidebar-scroll py-3 px-2 space-y-4",
|
"flex-1 overflow-y-auto custom-sidebar-scroll px-2 py-3 space-y-5",
|
||||||
isDesktopSidebarCollapsed && "md:px-3"
|
isDesktopSidebarCollapsed && "md:px-3"
|
||||||
)}>
|
)}>
|
||||||
{filteredSections.map((section) => (
|
{filteredSections.map((section) => (
|
||||||
<div key={section.label}>
|
<div key={section.label}>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"px-3 mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider",
|
"mb-1.5 px-2 text-[10px] font-semibold text-[#8ca0bb] uppercase tracking-[0.18em]",
|
||||||
isDesktopSidebarCollapsed && "md:sr-only"
|
isDesktopSidebarCollapsed && "md:sr-only"
|
||||||
)}>
|
)}>
|
||||||
{section.label}
|
{section.label}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => {
|
||||||
|
const isProjectsItem = item.to === '/app/admin/projects'
|
||||||
|
|
||||||
|
if (isProjectsItem) {
|
||||||
|
return (
|
||||||
|
<div key={item.to}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
to={item.to}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'relative flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
'relative flex min-w-0 flex-1 items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
|
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary font-medium shadow-sm shadow-orange-500/10'
|
? 'bg-[#24282e] text-white font-semibold shadow-none [&>svg]:text-primary'
|
||||||
: 'text-muted-foreground hover:bg-primary/10 hover:text-foreground'
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{sidebarProjects.length > 0 && !isDesktopSidebarCollapsed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsProjectsNavOpen((open) => !open)}
|
||||||
|
className="flex h-8 w-7 shrink-0 items-center justify-center rounded-md text-[#8b98aa] transition-colors hover:bg-[#171b20] hover:text-white"
|
||||||
|
aria-label={isProjectsNavOpen ? 'Ocultar proyectos' : 'Mostrar proyectos'}
|
||||||
|
aria-expanded={isProjectsNavOpen}
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isProjectsNavOpen && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
|
||||||
|
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
||||||
|
{sidebarProjects.map((project) => {
|
||||||
|
const projectAlarmCount = getProjectAlarmCount(project.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={project.id}>
|
||||||
|
<NavLink
|
||||||
|
to={`/app/admin/projects/${project.id}`}
|
||||||
|
title={project.name}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-[#24282e] text-white'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
|
project.is_active ? 'bg-success' : 'bg-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
||||||
|
{projectAlarmCount > 0 && (
|
||||||
|
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
||||||
|
{projectAlarmCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.to}>
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
title={item.label}
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={isNavItemActive(item.to, location) ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
|
isDesktopSidebarCollapsed && 'md:justify-center md:px-0',
|
||||||
|
isNavItemActive(item.to, location)
|
||||||
|
? 'bg-[#24282e] text-white font-semibold shadow-none [&>svg]:text-primary'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
|
<span className={cn("flex-1 truncate", isDesktopSidebarCollapsed && "md:sr-only")}>{item.label}</span>
|
||||||
{item.to.includes('/alerts') && unacknowledgedCount > 0 && (
|
{item.to.includes('/alerts') && unacknowledgedCount > 0 && (
|
||||||
<Badge className={cn(
|
<Badge className={cn(
|
||||||
"ml-auto bg-destructive text-destructive-foreground text-[10px] border-0 px-1.5 min-w-[18px] text-center",
|
"ml-auto bg-destructive text-destructive-foreground text-[10px] border-0 px-1.5 min-w-[18px] text-center shadow-sm shadow-red-500/20",
|
||||||
isDesktopSidebarCollapsed && "md:absolute md:right-1 md:top-1 md:ml-0 md:min-w-0 md:px-1"
|
isDesktopSidebarCollapsed && "md:absolute md:right-1 md:top-1 md:ml-0 md:min-w-0 md:px-1"
|
||||||
)}>
|
)}>
|
||||||
{unacknowledgedCount}
|
{unacknowledgedCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</Link>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{section.label === 'General' && sidebarWells.length > 0 && !isDesktopSidebarCollapsed && (
|
||||||
|
<div className="mt-2 space-y-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsWellsNavOpen((open) => !open)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
|
isWellRouteActive
|
||||||
|
? 'bg-[#24282e] text-white font-semibold [&>svg]:text-primary'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
||||||
|
)}
|
||||||
|
aria-expanded={isWellsNavOpen}
|
||||||
|
aria-controls="admin-sidebar-wells"
|
||||||
|
>
|
||||||
|
<Gauge className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 text-left">Pozos</span>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isWellsNavOpen && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isWellsNavOpen && (
|
||||||
|
<div id="admin-sidebar-wells" className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
||||||
|
{sidebarWells.map((well) => {
|
||||||
|
const wellAlarmCount = getWellAlarmCount(well.id)
|
||||||
|
const wellLabel = well.name || well.code || well.id.slice(0, 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={well.id}
|
||||||
|
to={`/app/admin/wells/${well.id}`}
|
||||||
|
title={wellLabel}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-[#24282e] text-white'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
|
well.is_active ? 'bg-success' : 'bg-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{wellLabel}</span>
|
||||||
|
{wellAlarmCount > 0 && (
|
||||||
|
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
||||||
|
{wellAlarmCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:block h-4 shrink-0 border-t border-white/10" />
|
<div className="hidden md:block h-4 shrink-0 border-t border-[#232831]" />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-14 md:pt-0">
|
<main className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-14 md:pt-0">
|
||||||
<header
|
<header
|
||||||
aria-label="Barra superior del dashboard"
|
aria-label="Barra superior del dashboard"
|
||||||
className="hidden md:flex h-20 shrink-0 items-center justify-between gap-4 border-b border-white/10 bg-card/80 px-6 backdrop-blur-md"
|
className="hidden h-20 shrink-0 items-center justify-between gap-4 border-b border-[#232831] bg-[#0b0f14]/90 px-6 backdrop-blur-md md:flex"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDesktopSidebarCollapsed((collapsed) => !collapsed)}
|
onClick={() => setIsDesktopSidebarCollapsed((collapsed) => !collapsed)}
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-background/50 text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/10 hover:text-primary"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/30 bg-primary text-primary-foreground shadow-lg shadow-orange-500/20 transition-colors hover:bg-primary/90"
|
||||||
aria-label={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
aria-label={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
||||||
title={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
title={isDesktopSidebarCollapsed ? 'Expandir navegación lateral' : 'Colapsar navegación lateral'}
|
||||||
aria-pressed={isDesktopSidebarCollapsed}
|
aria-pressed={isDesktopSidebarCollapsed}
|
||||||
@@ -276,9 +461,9 @@ export default function AdminLayout() {
|
|||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 shadow-lg shadow-orange-500/10">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 shadow-lg shadow-orange-500/10">
|
||||||
<img src="/icon.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
<img src="/icon.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 border border-white/10 px-3 py-2 transition-colors hover:border-primary/40 hover:bg-primary/10 rounded-xl" data-testid="admin-current-location">
|
<div className="min-w-0 rounded-xl border border-[#2a3038] bg-[#15191e] px-3 py-2 transition-colors hover:border-primary/40 hover:bg-primary/10" data-testid="admin-current-location">
|
||||||
<div className="text-[10px] font-bold uppercase tracking-[0.26em] text-primary/80">
|
<div className="text-[10px] font-bold uppercase tracking-[0.26em] text-[#9aa8ba]">
|
||||||
Consola Admin
|
CONSOLA ADMIN
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-0.5 truncate text-2xl font-bold tracking-tight text-foreground">{currentSectionLabel}</h1>
|
<h1 className="mt-0.5 truncate text-2xl font-bold tracking-tight text-foreground">{currentSectionLabel}</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +474,7 @@ export default function AdminLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/app/admin/alerts')}
|
onClick={() => navigate('/app/admin/alerts')}
|
||||||
className="relative rounded-full border border-white/10 bg-background/50 p-2.5 text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary"
|
className="relative rounded-full border border-[#2a3038] bg-[#15191e] p-2.5 text-muted-foreground transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
aria-label={alertsButtonLabel}
|
aria-label={alertsButtonLabel}
|
||||||
title={alertsButtonLabel}
|
title={alertsButtonLabel}
|
||||||
>
|
>
|
||||||
@@ -298,7 +483,7 @@ export default function AdminLayout() {
|
|||||||
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card" />
|
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-background/50 py-1.5 pl-1.5 pr-2 shadow-sm shadow-black/10">
|
<div className="flex items-center gap-3 rounded-full border border-[#2a3038] bg-[#15191e] py-1.5 pl-1.5 pr-2 shadow-sm shadow-black/10">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-sm font-semibold text-primary">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-sm font-semibold text-primary">
|
||||||
{userInitial}
|
{userInitial}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('admin router navigation contract', () => {
|
|||||||
section.items
|
section.items
|
||||||
.map((item) => item.to)
|
.map((item) => item.to)
|
||||||
.filter((path) => path.startsWith('/app/admin/'))
|
.filter((path) => path.startsWith('/app/admin/'))
|
||||||
.map((path) => path.replace('/app/admin/', ''))
|
.map((path) => path.replace('/app/admin/', '').split('?')[0])
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(adminChildRoutePaths).toEqual(expect.arrayContaining(navigationPaths))
|
expect(adminChildRoutePaths).toEqual(expect.arrayContaining(navigationPaths))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SetupPage from '@/features/auth/pages/SetupPage'
|
|||||||
import ActivatePage from '@/features/auth/pages/ActivatePage'
|
import ActivatePage from '@/features/auth/pages/ActivatePage'
|
||||||
import AdminLayout from '@/app/layouts/AdminLayout'
|
import AdminLayout from '@/app/layouts/AdminLayout'
|
||||||
import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage'
|
import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage'
|
||||||
|
import RealtimePage from '@/features/admin/dashboard/pages/RealtimePage'
|
||||||
import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage'
|
import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage'
|
||||||
import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage'
|
import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage'
|
||||||
import ProjectDetailsPage from '@/features/admin/projects/pages/ProjectDetailsPage'
|
import ProjectDetailsPage from '@/features/admin/projects/pages/ProjectDetailsPage'
|
||||||
@@ -17,11 +18,13 @@ const ANHReportPage = lazy(() => import('@/features/admin/anh-reports/pages/ANHR
|
|||||||
const AlertsPage = lazy(() => import('@/features/admin/alerts/pages/AlertsPage'))
|
const AlertsPage = lazy(() => import('@/features/admin/alerts/pages/AlertsPage'))
|
||||||
const SettingsPage = lazy(() => import('@/features/admin/settings/pages/SettingsPage'))
|
const SettingsPage = lazy(() => import('@/features/admin/settings/pages/SettingsPage'))
|
||||||
const BillingPage = lazy(() => import('@/features/admin/billing/pages/BillingPage'))
|
const BillingPage = lazy(() => import('@/features/admin/billing/pages/BillingPage'))
|
||||||
|
const WellDashboardPage = lazy(() => import('@/features/admin/dashboard/pages/WellDashboardPage'))
|
||||||
|
|
||||||
export const adminChildRoutes = [
|
export const adminChildRoutes = [
|
||||||
{ index: true, element: <Navigate to="dashboard" replace /> },
|
{ index: true, element: <Navigate to="dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
{ path: 'dashboard', element: <DashboardPage /> },
|
||||||
{ path: 'realtime', element: <DashboardPage /> },
|
{ path: 'realtime', element: <RealtimePage /> },
|
||||||
|
{ path: 'wells/:wellId', element: <Suspense fallback={null}><WellDashboardPage /></Suspense> },
|
||||||
{ path: 'projects', element: <ProjectListPage /> },
|
{ path: 'projects', element: <ProjectListPage /> },
|
||||||
{ path: 'projects/new', element: <CreateProjectPage /> },
|
{ path: 'projects/new', element: <CreateProjectPage /> },
|
||||||
{ path: 'projects/:id', element: <ProjectDetailsPage /> },
|
{ path: 'projects/:id', element: <ProjectDetailsPage /> },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6 rounded-xl border border-white/10 bg-card/90 py-6 text-card-foreground shadow-2xl shadow-black/20 backdrop-blur-sm",
|
"flex flex-col gap-6 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-2xl shadow-black/20 backdrop-blur-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ describe('admin UI primitive visual language', () => {
|
|||||||
const button = container?.querySelector('[data-slot="button"]')
|
const button = container?.querySelector('[data-slot="button"]')
|
||||||
const badge = container?.querySelector('[data-slot="badge"]')
|
const badge = container?.querySelector('[data-slot="badge"]')
|
||||||
|
|
||||||
expect(card?.className).toContain('bg-card/90')
|
expect(card?.className).toContain('bg-card')
|
||||||
expect(card?.className).toContain('border-white/10')
|
expect(card?.className).toContain('border-border')
|
||||||
expect(button?.textContent).toBe('Guardar')
|
expect(button?.textContent).toBe('Guardar')
|
||||||
expect(badge?.textContent).toBe('Activo')
|
expect(badge?.textContent).toBe('Activo')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
))
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 />
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
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
8271
services/frontend-pwa/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
5135
services/landing/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
services/landing/src/app/docs/[slug]/page.tsx
Normal file
49
services/landing/src/app/docs/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
services/landing/src/app/docs/docs-routes.test.tsx
Normal file
160
services/landing/src/app/docs/docs-routes.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
services/landing/src/app/docs/page.tsx
Normal file
20
services/landing/src/app/docs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
)),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
91
services/landing/src/components/docs/DocsBlockRenderer.tsx
Normal file
91
services/landing/src/components/docs/DocsBlockRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
services/landing/src/components/docs/DocsIndexContent.tsx
Normal file
36
services/landing/src/components/docs/DocsIndexContent.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
services/landing/src/components/docs/DocsLayout.tsx
Normal file
29
services/landing/src/components/docs/DocsLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
services/landing/src/components/docs/DocsPageHeader.tsx
Normal file
12
services/landing/src/components/docs/DocsPageHeader.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
services/landing/src/components/docs/DocsSidebar.tsx
Normal file
10
services/landing/src/components/docs/DocsSidebar.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
50
services/landing/src/components/docs/DocsSidebarContent.tsx
Normal file
50
services/landing/src/components/docs/DocsSidebarContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
services/landing/src/components/docs/DocsTopicContent.tsx
Normal file
52
services/landing/src/components/docs/DocsTopicContent.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />);
|
||||||
|
|
||||||
|
|||||||
@@ -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: "#" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
531
services/landing/src/content/docs.ts
Normal file
531
services/landing/src/content/docs.ts
Normal 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);
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user