Compare commits
66 Commits
wip/admin-
...
34754810ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34754810ba | ||
|
|
c3668d047f | ||
|
|
6315307e4a | ||
|
|
1f940e8164 | ||
|
|
959c89b5d3 | ||
|
|
df31ea1041 | ||
|
|
e661caa514 | ||
|
|
d9daa6e6e4 | ||
|
|
5b68e2c80e | ||
|
|
5b58da0f18 | ||
|
|
70d1922906 | ||
|
|
d97fc76f16 | ||
|
|
eaaf368053 | ||
|
|
40cd3ece33 | ||
|
|
333c3b3b21 | ||
|
|
5cd46f75a0 | ||
|
|
f250ad11de | ||
| c8a8089243 | |||
|
|
565b0c0a61 | ||
| 9b806d3fec | |||
|
|
64c42a02e4 | ||
|
|
338967b949 | ||
|
|
b331b05d55 | ||
|
|
6dd7754206 | ||
| 30a16e5b6c | |||
| e229f0ae50 | |||
| 47d31837e9 | |||
| 04ef63537c | |||
|
|
ce9ad1f8f0 | ||
|
|
d9c01647ef | ||
|
|
e615b06f7b | ||
|
|
9559eb4e7d | ||
|
|
311a5422a9 | ||
|
|
132be581f5 | ||
|
|
c43642218e | ||
|
|
91181431aa | ||
| ac411b1893 | |||
|
|
d70efd6b5e | ||
| 49431911fc | |||
|
|
f28cb4dad7 | ||
| baa906001e | |||
|
|
f9151dd9ba | ||
|
|
f009c43bd2 | ||
|
|
113be509ee | ||
|
|
439b80dca8 | ||
| 51e27e49f4 | |||
|
|
948b372f40 | ||
|
|
3c6cc42216 | ||
|
|
675b3033a4 | ||
|
|
b5357a3d0c | ||
| 761a4ab5a4 | |||
|
|
89907fbb58 | ||
|
|
3dfe4c5659 | ||
|
|
741034ac39 | ||
|
|
8cfd7a085b | ||
|
|
88dc61512f | ||
|
|
18d8ffa375 | ||
|
|
c1254566c1 | ||
| d5c768d9c3 | |||
| b7f1873504 | |||
|
|
fac9ab3fe0 | ||
|
|
3417d8afaf | ||
|
|
e6f5412ca2 | ||
|
|
29e89a0a41 | ||
|
|
3caf9ba42b | ||
|
|
acbf3cc56a |
@@ -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
|
||||||
|
|||||||
@@ -52,10 +52,15 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin backend-api
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY . .
|
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
||||||
# Copiar caché del cacher
|
COPY --from=cacher /app /app
|
||||||
COPY --from=cacher /app/target target
|
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
|
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
|
COPY services/json-generator services/json-generator
|
||||||
|
COPY services/backend-api services/backend-api
|
||||||
|
COPY .sqlx .sqlx
|
||||||
RUN cargo build --release --bin backend-api
|
RUN cargo build --release --bin backend-api
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
ALTER TABLE telemetry_raw
|
||||||
|
ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'SCADA',
|
||||||
|
ADD COLUMN IF NOT EXISTS client_id TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE telemetry_raw
|
||||||
|
ALTER COLUMN source SET DEFAULT 'SCADA';
|
||||||
|
|
||||||
|
ALTER TABLE telemetry_raw
|
||||||
|
DROP CONSTRAINT IF EXISTS telemetry_raw_source_check;
|
||||||
|
|
||||||
|
ALTER TABLE telemetry_raw
|
||||||
|
ADD CONSTRAINT telemetry_raw_source_check
|
||||||
|
CHECK (source IN ('SCADA', 'PWA', 'MANUAL'));
|
||||||
|
|
||||||
|
UPDATE telemetry_raw
|
||||||
|
SET source = 'SCADA'
|
||||||
|
WHERE source IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE telemetry_raw
|
||||||
|
ALTER COLUMN source SET NOT NULL;
|
||||||
|
|
||||||
|
-- Make the idempotency index migration-safe for databases that already contain
|
||||||
|
-- exact duplicate telemetry samples. Keep one row per existing (time, asset_id)
|
||||||
|
-- pair and delete only the extra rows in those duplicate pairs. `asset_id` was
|
||||||
|
-- nullable in legacy telemetry_raw, so `IS NOT DISTINCT FROM` preserves exact
|
||||||
|
-- duplicate-pair semantics for NULL asset_id without broad cleanup. Hypertables
|
||||||
|
-- can span multiple chunks, so identify physical rows by (tableoid, ctid), not
|
||||||
|
-- ctid alone.
|
||||||
|
WITH duplicate_pairs AS (
|
||||||
|
SELECT time, asset_id
|
||||||
|
FROM telemetry_raw
|
||||||
|
GROUP BY time, asset_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
), ranked_duplicates AS (
|
||||||
|
SELECT
|
||||||
|
t.tableoid,
|
||||||
|
t.ctid,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY t.time, t.asset_id ORDER BY t.tableoid, t.ctid) AS duplicate_rank
|
||||||
|
FROM telemetry_raw t
|
||||||
|
JOIN duplicate_pairs d
|
||||||
|
ON t.time IS NOT DISTINCT FROM d.time
|
||||||
|
AND t.asset_id IS NOT DISTINCT FROM d.asset_id
|
||||||
|
)
|
||||||
|
DELETE FROM telemetry_raw t
|
||||||
|
USING ranked_duplicates d
|
||||||
|
WHERE t.tableoid = d.tableoid
|
||||||
|
AND t.ctid = d.ctid
|
||||||
|
AND d.duplicate_rank > 1;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telemetry_raw_time_asset_unique
|
||||||
|
ON telemetry_raw (time, asset_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_raw_project_source_time
|
||||||
|
ON telemetry_raw (project_id, source, time DESC);
|
||||||
@@ -11,6 +11,7 @@ struct TelemetryRow {
|
|||||||
asset_code: String,
|
asset_code: String,
|
||||||
time: DateTime<Utc>,
|
time: DateTime<Utc>,
|
||||||
data: Value,
|
data: Value,
|
||||||
|
source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
|
pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
|
||||||
@@ -22,7 +23,8 @@ pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl Int
|
|||||||
t.asset_id,
|
t.asset_id,
|
||||||
a.code as asset_code,
|
a.code as asset_code,
|
||||||
t.time,
|
t.time,
|
||||||
t.data
|
t.data,
|
||||||
|
t.source
|
||||||
FROM telemetry_raw t
|
FROM telemetry_raw t
|
||||||
JOIN edge_assets a ON t.asset_id = a.id
|
JOIN edge_assets a ON t.asset_id = a.id
|
||||||
JOIN user_project_access upa ON a.project_id = upa.project_id
|
JOIN user_project_access upa ON a.project_id = upa.project_id
|
||||||
@@ -43,6 +45,7 @@ pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl Int
|
|||||||
"asset_id": row.asset_id,
|
"asset_id": row.asset_id,
|
||||||
"asset": row.asset_code,
|
"asset": row.asset_code,
|
||||||
"last_updated": row.time,
|
"last_updated": row.time,
|
||||||
|
"source": row.source,
|
||||||
"telemetry": row.data
|
"telemetry": row.data
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,38 +12,76 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
|
use shared_lib::models::{
|
||||||
|
PostTelemetryRequest, TelemetryRecord, TelemetryResponse, TelemetrySource,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT).
|
/// POST /api/telemetry — Official LF telemetry channel for PWA/manual clients.
|
||||||
///
|
///
|
||||||
/// Los agentes de campo envían telemetría exclusivamente por MQTT (`omnioil/telemetry/{id}`),
|
/// SCADA/HF agents send telemetry by MQTT (`omnioil/telemetry/{project_id}`), never by HTTP.
|
||||||
/// nunca por HTTP. Este endpoint es únicamente para el frontend administrativo.
|
|
||||||
///
|
///
|
||||||
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados.
|
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados.
|
||||||
pub async fn post_telemetry(
|
pub async fn post_telemetry(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
_claims: Claims,
|
claims: Claims,
|
||||||
Json(req): Json<PostTelemetryRequest>,
|
Json(req): Json<PostTelemetryRequest>,
|
||||||
) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> {
|
||||||
let time = req.time.unwrap_or_else(Utc::now);
|
let time = req.time.unwrap_or_else(Utc::now);
|
||||||
|
let source = resolve_http_telemetry_source(req.source)?;
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"User ID inválido en token".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
sqlx::query(
|
let asset_project_id = sqlx::query_scalar::<_, Uuid>(asset_project_sql())
|
||||||
r#"INSERT INTO telemetry_raw (time, asset_id, data)
|
.bind(req.asset_id)
|
||||||
VALUES ($1, $2, $3)
|
.fetch_optional(&pool)
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING"#,
|
.await
|
||||||
)
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.bind(time)
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
|
||||||
.bind(req.asset_id)
|
|
||||||
.bind(&req.data)
|
let has_access = sqlx::query_scalar::<_, bool>(active_project_access_sql())
|
||||||
.execute(&pool)
|
.bind(user_id)
|
||||||
.await
|
.bind(asset_project_id)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_access {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to this asset's telemetry".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query(post_telemetry_insert_sql())
|
||||||
|
.bind(time)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(&req.data)
|
||||||
|
.bind(source)
|
||||||
|
.bind(&req.client_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
asset_id = %req.asset_id,
|
||||||
|
time = %time,
|
||||||
|
"Telemetry record already exists; treating POST as idempotent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
asset_id = %req.asset_id,
|
asset_id = %req.asset_id,
|
||||||
time = %time,
|
time = %time,
|
||||||
|
source = ?source,
|
||||||
"Telemetry record inserted"
|
"Telemetry record inserted"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,6 +89,8 @@ pub async fn post_telemetry(
|
|||||||
time,
|
time,
|
||||||
asset_id: req.asset_id,
|
asset_id: req.asset_id,
|
||||||
data: req.data,
|
data: req.data,
|
||||||
|
source,
|
||||||
|
client_id: req.client_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(response)))
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
@@ -108,7 +148,7 @@ pub async fn get_asset_telemetry(
|
|||||||
let since = Utc::now() - Duration::days(31);
|
let since = Utc::now() - Duration::days(31);
|
||||||
|
|
||||||
let records = sqlx::query_as::<_, TelemetryRecord>(
|
let records = sqlx::query_as::<_, TelemetryRecord>(
|
||||||
r#"SELECT time, asset_id, data
|
r#"SELECT time, asset_id, data, source, client_id
|
||||||
FROM telemetry_raw
|
FROM telemetry_raw
|
||||||
WHERE asset_id = $1
|
WHERE asset_id = $1
|
||||||
AND time >= $2
|
AND time >= $2
|
||||||
@@ -125,6 +165,98 @@ pub async fn get_asset_telemetry(
|
|||||||
Ok(Json(records))
|
Ok(Json(records))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn post_telemetry_insert_sql() -> &'static str {
|
||||||
|
r#"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)
|
||||||
|
SELECT $1, ea.project_id, ea.id, $3, $4, $5
|
||||||
|
FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa
|
||||||
|
ON upa.project_id = ea.project_id
|
||||||
|
AND upa.user_id = $6
|
||||||
|
AND upa.is_active = true
|
||||||
|
WHERE ea.id = $2
|
||||||
|
ON CONFLICT (time, asset_id) DO NOTHING"#
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn asset_project_sql() -> &'static str {
|
||||||
|
"SELECT project_id FROM edge_assets WHERE id = $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_project_access_sql() -> &'static str {
|
||||||
|
r#"SELECT true
|
||||||
|
FROM user_project_access
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
AND is_active = true"#
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_http_telemetry_source(
|
||||||
|
source: Option<TelemetrySource>,
|
||||||
|
) -> Result<TelemetrySource, (StatusCode, String)> {
|
||||||
|
match source.unwrap_or(TelemetrySource::Pwa) {
|
||||||
|
TelemetrySource::Scada => Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"SCADA telemetry must be ingested via MQTT, not HTTP".to_string(),
|
||||||
|
)),
|
||||||
|
source @ (TelemetrySource::Pwa | TelemetrySource::Manual) => Ok(source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pwa_insert_derives_project_id_from_asset_and_persists_source_client() {
|
||||||
|
let sql = post_telemetry_insert_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains(
|
||||||
|
"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)"
|
||||||
|
));
|
||||||
|
assert!(sql.contains("SELECT $1, ea.project_id, ea.id, $3, $4, $5"));
|
||||||
|
assert!(sql.contains("FROM edge_assets ea"));
|
||||||
|
assert!(sql.contains("JOIN user_project_access upa"));
|
||||||
|
assert!(sql.contains("upa.project_id = ea.project_id"));
|
||||||
|
assert!(sql.contains("upa.user_id = $6"));
|
||||||
|
assert!(sql.contains("upa.is_active = true"));
|
||||||
|
assert!(sql.contains("WHERE ea.id = $2"));
|
||||||
|
assert!(sql.contains("ON CONFLICT (time, asset_id) DO NOTHING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_source_defaults_to_pwa_and_allows_manual() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_telemetry_source(None).unwrap(),
|
||||||
|
TelemetrySource::Pwa
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_telemetry_source(Some(TelemetrySource::Manual)).unwrap(),
|
||||||
|
TelemetrySource::Manual
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_source_rejects_scada() {
|
||||||
|
let err = resolve_http_telemetry_source(Some(TelemetrySource::Scada)).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(err.1.contains("SCADA telemetry must be ingested via MQTT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_telemetry_checks_asset_then_active_user_project_access() {
|
||||||
|
assert_eq!(
|
||||||
|
asset_project_sql(),
|
||||||
|
"SELECT project_id FROM edge_assets WHERE id = $1"
|
||||||
|
);
|
||||||
|
|
||||||
|
let access_sql = active_project_access_sql();
|
||||||
|
assert!(access_sql.contains("FROM user_project_access"));
|
||||||
|
assert!(access_sql.contains("user_id = $1"));
|
||||||
|
assert!(access_sql.contains("project_id = $2"));
|
||||||
|
assert!(access_sql.contains("is_active = true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/telemetry/:asset_id/history — Historial largo (agregado de 5 minutos).
|
/// GET /api/telemetry/:asset_id/history — Historial largo (agregado de 5 minutos).
|
||||||
///
|
///
|
||||||
/// Usa `telemetry_5min` (vista continua de TimescaleDB), que retiene datos por 5 años.
|
/// Usa `telemetry_5min` (vista continua de TimescaleDB), que retiene datos por 5 años.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod errors;
|
|||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod invitation_tokens;
|
pub mod invitation_tokens;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod mqtt_telemetry;
|
||||||
pub mod notifier;
|
pub mod notifier;
|
||||||
pub mod passwords;
|
pub mod passwords;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use axum::{
|
|||||||
routing::{get, patch, post, put},
|
routing::{get, patch, post, put},
|
||||||
};
|
};
|
||||||
use backend_api::{
|
use backend_api::{
|
||||||
AppState, TelemetryEvent, api_version, auth, crypto, db, handlers, metrics, notifier,
|
AppState, TelemetryEvent, api_version, auth, crypto, db, handlers, metrics, mqtt_telemetry,
|
||||||
rate_limiter, watchdog,
|
notifier, rate_limiter, watchdog,
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -129,6 +129,7 @@ async fn main() {
|
|||||||
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
||||||
|
|
||||||
let (tx, _) = broadcast::channel::<TelemetryEvent>(1000);
|
let (tx, _) = broadcast::channel::<TelemetryEvent>(1000);
|
||||||
|
let device_resolver_cache = mqtt_telemetry::new_device_resolver_cache();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
mqtt_client: mqtt_client.clone(),
|
mqtt_client: mqtt_client.clone(),
|
||||||
@@ -323,6 +324,34 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if topic.starts_with("omnioil/telemetry/") {
|
||||||
|
let Some(topic_project_id) =
|
||||||
|
mqtt_telemetry::telemetry_project_id_from_topic(topic)
|
||||||
|
else {
|
||||||
|
tracing::warn!(
|
||||||
|
topic = %topic,
|
||||||
|
"MQTT telemetry dropped: invalid project_id in topic"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let pool_ref = prov_pool.clone();
|
||||||
|
let tx_ref = tx_relay.clone();
|
||||||
|
let cache_ref = device_resolver_cache.clone();
|
||||||
|
let payload_bytes = publish.payload.to_vec();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = mqtt_telemetry::handle_edge_telemetry(
|
||||||
|
&pool_ref,
|
||||||
|
&tx_ref,
|
||||||
|
&cache_ref,
|
||||||
|
topic_project_id,
|
||||||
|
&payload_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("⚠️ MQTT telemetry handling failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if topic.starts_with("omnioil/") {
|
} else if topic.starts_with("omnioil/") {
|
||||||
// ── Relay genérico: telemetría, alarmas, deploys → WebSocket ──
|
// ── Relay genérico: telemetría, alarmas, deploys → WebSocket ──
|
||||||
let topic_parts: Vec<&str> = topic.split('/').collect();
|
let topic_parts: Vec<&str> = topic.split('/').collect();
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ pub fn register_metrics() {
|
|||||||
"omnioil_telemetry_messages_total",
|
"omnioil_telemetry_messages_total",
|
||||||
"Total telemetry MQTT messages processed"
|
"Total telemetry MQTT messages processed"
|
||||||
);
|
);
|
||||||
|
describe_counter!(
|
||||||
|
"omnioil_telemetry_unresolved_device_total",
|
||||||
|
"MQTT telemetry messages dropped because the device could not be resolved"
|
||||||
|
);
|
||||||
|
describe_counter!(
|
||||||
|
"omnioil_telemetry_ambiguous_device_total",
|
||||||
|
"MQTT telemetry messages dropped because device resolution matched multiple assets"
|
||||||
|
);
|
||||||
|
describe_counter!(
|
||||||
|
"omnioil_telemetry_project_mismatch_total",
|
||||||
|
"MQTT telemetry messages dropped because payload project_id mismatched the topic project_id"
|
||||||
|
);
|
||||||
describe_counter!(
|
describe_counter!(
|
||||||
"omnioil_alarms_triggered_total",
|
"omnioil_alarms_triggered_total",
|
||||||
"Total threshold alarms triggered"
|
"Total threshold alarms triggered"
|
||||||
@@ -29,6 +41,18 @@ pub fn record_alarm_triggered() {
|
|||||||
counter!("omnioil_alarms_triggered_total").increment(1);
|
counter!("omnioil_alarms_triggered_total").increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_telemetry_unresolved_device() {
|
||||||
|
counter!("omnioil_telemetry_unresolved_device_total").increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_telemetry_ambiguous_device() {
|
||||||
|
counter!("omnioil_telemetry_ambiguous_device_total").increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_telemetry_project_mismatch() {
|
||||||
|
counter!("omnioil_telemetry_project_mismatch_total").increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
pub async fn update_system_gauges(pool: &sqlx::PgPool) {
|
||||||
if let Ok(count) = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_projects")
|
if let Ok(count) = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_projects")
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
253
services/backend-api/src/mqtt_telemetry.rs
Normal file
253
services/backend-api/src/mqtt_telemetry.rs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::TelemetryEvent;
|
||||||
|
|
||||||
|
pub type DeviceResolverCache = Arc<RwLock<HashMap<DeviceResolverKey, DeviceResolution>>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DeviceResolverKey {
|
||||||
|
pub project_id: Uuid,
|
||||||
|
pub device_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum DeviceResolution {
|
||||||
|
Resolved(Uuid),
|
||||||
|
Unresolved,
|
||||||
|
Ambiguous,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EdgeTelemetryPayload {
|
||||||
|
pub project_id: Option<Uuid>,
|
||||||
|
pub device_name: String,
|
||||||
|
pub values: serde_json::Value,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_device_resolver_cache() -> DeviceResolverCache {
|
||||||
|
Arc::new(RwLock::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_edge_telemetry(
|
||||||
|
pool: &PgPool,
|
||||||
|
tx: &broadcast::Sender<TelemetryEvent>,
|
||||||
|
cache: &DeviceResolverCache,
|
||||||
|
topic_project_id: Uuid,
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let decompressed = shared_lib::compression::decompress_if_needed(payload_bytes);
|
||||||
|
let payload: EdgeTelemetryPayload = serde_json::from_slice(decompressed.as_ref())?;
|
||||||
|
|
||||||
|
crate::metrics::record_telemetry_received();
|
||||||
|
|
||||||
|
if let Some(payload_project_id) = payload.project_id {
|
||||||
|
if !payload_project_matches_topic(Some(payload_project_id), topic_project_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
topic_project_id = %topic_project_id,
|
||||||
|
payload_project_id = %payload_project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: payload project_id does not match authoritative topic project_id"
|
||||||
|
);
|
||||||
|
crate::metrics::record_telemetry_project_mismatch();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolution = resolve_device(pool, cache, topic_project_id, &payload.device_name).await?;
|
||||||
|
let asset_id = match resolution {
|
||||||
|
DeviceResolution::Resolved(asset_id) => asset_id,
|
||||||
|
DeviceResolution::Unresolved => {
|
||||||
|
tracing::warn!(
|
||||||
|
project_id = %topic_project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: device could not be resolved"
|
||||||
|
);
|
||||||
|
crate::metrics::record_telemetry_unresolved_device();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
DeviceResolution::Ambiguous => {
|
||||||
|
tracing::warn!(
|
||||||
|
project_id = %topic_project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: device resolution is ambiguous"
|
||||||
|
);
|
||||||
|
crate::metrics::record_telemetry_ambiguous_device();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(insert_scada_telemetry_sql())
|
||||||
|
.bind(payload.timestamp)
|
||||||
|
.bind(topic_project_id)
|
||||||
|
.bind(asset_id)
|
||||||
|
.bind(&payload.values)
|
||||||
|
.bind(&payload.device_name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ws_payload = serde_json::json!({
|
||||||
|
"type": "telemetry",
|
||||||
|
"project_id": topic_project_id,
|
||||||
|
"asset_id": asset_id,
|
||||||
|
"device_name": payload.device_name,
|
||||||
|
"source": "SCADA",
|
||||||
|
"frequency_class": "HF",
|
||||||
|
"timestamp": payload.timestamp,
|
||||||
|
"values": payload.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = tx.send(TelemetryEvent {
|
||||||
|
project_id: Arc::from(topic_project_id.to_string()),
|
||||||
|
payload: Arc::from(ws_payload.to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn payload_project_matches_topic(
|
||||||
|
payload_project_id: Option<Uuid>,
|
||||||
|
topic_project_id: Uuid,
|
||||||
|
) -> bool {
|
||||||
|
match payload_project_id {
|
||||||
|
Some(payload_project_id) => payload_project_id == topic_project_id,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn telemetry_project_id_from_topic(topic: &str) -> Option<Uuid> {
|
||||||
|
topic
|
||||||
|
.strip_prefix("omnioil/telemetry/")
|
||||||
|
.and_then(|tail| tail.split('/').next())
|
||||||
|
.filter(|project_id| !project_id.is_empty())
|
||||||
|
.and_then(|project_id| Uuid::parse_str(project_id).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_device(
|
||||||
|
pool: &PgPool,
|
||||||
|
cache: &DeviceResolverCache,
|
||||||
|
project_id: Uuid,
|
||||||
|
device_name: &str,
|
||||||
|
) -> anyhow::Result<DeviceResolution> {
|
||||||
|
let key = DeviceResolverKey {
|
||||||
|
project_id,
|
||||||
|
device_name: device_name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(cached) = cache.read().await.get(&key).cloned() {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(resolve_device_sql())
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(device_name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let resolution = match rows.len() {
|
||||||
|
0 => DeviceResolution::Unresolved,
|
||||||
|
1 => DeviceResolution::Resolved(rows[0].get("asset_id")),
|
||||||
|
_ => DeviceResolution::Ambiguous,
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(resolution, DeviceResolution::Resolved(_)) {
|
||||||
|
cache.write().await.insert(key, resolution.clone());
|
||||||
|
}
|
||||||
|
Ok(resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_device_sql() -> &'static str {
|
||||||
|
r#"SELECT d.asset_id
|
||||||
|
FROM edge_devices d
|
||||||
|
JOIN edge_assets a ON d.asset_id = a.id
|
||||||
|
WHERE a.project_id = $1
|
||||||
|
AND d.name = $2"#
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_scada_telemetry_sql() -> &'static str {
|
||||||
|
r#"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)
|
||||||
|
VALUES ($1, $2, $3, $4, 'SCADA', $5)
|
||||||
|
ON CONFLICT (time, asset_id) DO NOTHING"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_uses_strict_project_device_join_without_schema_shortcut() {
|
||||||
|
let sql = resolve_device_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("FROM edge_devices d"));
|
||||||
|
assert!(sql.contains("JOIN edge_assets a ON d.asset_id = a.id"));
|
||||||
|
assert!(sql.contains("a.project_id = $1"));
|
||||||
|
assert!(sql.contains("d.name = $2"));
|
||||||
|
assert!(!sql.contains("d.project_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scada_insert_never_allows_null_asset_and_persists_source_client() {
|
||||||
|
let sql = insert_scada_telemetry_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains(
|
||||||
|
"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)"
|
||||||
|
));
|
||||||
|
assert!(sql.contains("VALUES ($1, $2, $3, $4, 'SCADA', $5)"));
|
||||||
|
assert!(sql.contains("ON CONFLICT (time, asset_id) DO NOTHING"));
|
||||||
|
assert!(!sql.contains("NULL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_topic_project_id_parser_accepts_only_valid_telemetry_topics() {
|
||||||
|
let project_id = Uuid::new_v4();
|
||||||
|
let topic = format!("omnioil/telemetry/{project_id}");
|
||||||
|
|
||||||
|
assert_eq!(telemetry_project_id_from_topic(&topic), Some(project_id));
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_project_id_from_topic(&format!("omnioil/telemetry/{project_id}/extra")),
|
||||||
|
Some(project_id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_project_id_from_topic("omnioil/telemetry/not-a-uuid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_project_id_from_topic("omnioil/status/not-a-uuid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edge_payload_accepts_missing_legacy_project_id() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"device_name": "pump-1",
|
||||||
|
"values": { "pressure": 10 },
|
||||||
|
"timestamp": "2026-06-27T00:00:00Z"
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed: EdgeTelemetryPayload = serde_json::from_value(payload).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.project_id, None);
|
||||||
|
assert_eq!(parsed.device_name, "pump-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_project_id_must_match_authoritative_topic_when_present() {
|
||||||
|
let topic_project_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
assert!(payload_project_matches_topic(None, topic_project_id));
|
||||||
|
assert!(payload_project_matches_topic(
|
||||||
|
Some(topic_project_id),
|
||||||
|
topic_project_id
|
||||||
|
));
|
||||||
|
assert!(!payload_project_matches_topic(
|
||||||
|
Some(Uuid::new_v4()),
|
||||||
|
topic_project_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,17 @@ use std::time::Duration;
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const TELEMETRY_SILENCE_HF: &str = "TELEMETRY_SILENCE_HF";
|
||||||
|
const TELEMETRY_SILENCE_LF: &str = "TELEMETRY_SILENCE_LF";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct SilenceSpec {
|
||||||
|
source: &'static str,
|
||||||
|
frequency_class: &'static str,
|
||||||
|
alarm_type: &'static str,
|
||||||
|
window: chrono::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) {
|
pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) {
|
||||||
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
|
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
|
||||||
|
|
||||||
@@ -55,73 +66,133 @@ async fn check_telemetry_silence(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
tx: &broadcast::Sender<crate::TelemetryEvent>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
|
for spec in telemetry_silence_specs() {
|
||||||
let rows = sqlx::query(
|
let window = format_window_interval(spec.window);
|
||||||
r#"
|
let rows = sqlx::query(telemetry_silence_select_sql())
|
||||||
|
.bind(spec.frequency_class)
|
||||||
|
.bind(&window)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let proj_id: Uuid = row.get("id");
|
||||||
|
let proj_name: String = row.get("name");
|
||||||
|
let msg = format!(
|
||||||
|
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
|
||||||
|
source = spec.source,
|
||||||
|
frequency_class = spec.frequency_class,
|
||||||
|
);
|
||||||
|
|
||||||
|
let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
|
||||||
|
.bind(proj_id)
|
||||||
|
.bind(spec.alarm_type)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if already_alerted.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
source = spec.source,
|
||||||
|
frequency_class = spec.frequency_class,
|
||||||
|
"⚠️ Detectado silencio prolongado en project: {}",
|
||||||
|
proj_name
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
|
VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
|
||||||
|
)
|
||||||
|
.bind(proj_id)
|
||||||
|
.bind(spec.alarm_type)
|
||||||
|
.bind(&msg)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let alarm = AlarmNotification {
|
||||||
|
project_id: proj_id,
|
||||||
|
asset_id: None,
|
||||||
|
variable: spec.alarm_type.to_string(),
|
||||||
|
value: 0.0,
|
||||||
|
alarm_type: spec.alarm_type.to_string(),
|
||||||
|
message: msg,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
notifier::dispatch(pool, &alarm, tx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telemetry_silence_specs() -> [SilenceSpec; 2] {
|
||||||
|
let hf_minutes = std::env::var("OMNIOIL_TELEMETRY_SILENCE_HF_MINUTES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
|
.filter(|value| *value > 0);
|
||||||
|
let lf_hours = std::env::var("OMNIOIL_TELEMETRY_SILENCE_LF_HOURS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
|
.filter(|value| *value > 0);
|
||||||
|
|
||||||
|
telemetry_silence_specs_from_values(hf_minutes, lf_hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telemetry_silence_specs_from_values(
|
||||||
|
hf_minutes: Option<i64>,
|
||||||
|
lf_hours: Option<i64>,
|
||||||
|
) -> [SilenceSpec; 2] {
|
||||||
|
let hf_minutes = hf_minutes.unwrap_or(10);
|
||||||
|
let lf_hours = lf_hours.unwrap_or(24);
|
||||||
|
|
||||||
|
[
|
||||||
|
SilenceSpec {
|
||||||
|
source: "SCADA",
|
||||||
|
frequency_class: "HF",
|
||||||
|
alarm_type: TELEMETRY_SILENCE_HF,
|
||||||
|
window: chrono::Duration::minutes(hf_minutes),
|
||||||
|
},
|
||||||
|
SilenceSpec {
|
||||||
|
source: "PWA|MANUAL",
|
||||||
|
frequency_class: "LF",
|
||||||
|
alarm_type: TELEMETRY_SILENCE_LF,
|
||||||
|
window: chrono::Duration::hours(lf_hours),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_window_interval(window: chrono::Duration) -> String {
|
||||||
|
format!("{} seconds", window.num_seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn telemetry_silence_select_sql() -> &'static str {
|
||||||
|
r#"
|
||||||
SELECT p.id, p.name
|
SELECT p.id, p.name
|
||||||
FROM edge_projects p
|
FROM edge_projects p
|
||||||
WHERE p.is_active = true
|
WHERE p.is_active = true
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM telemetry_raw t
|
SELECT 1 FROM telemetry_raw t
|
||||||
WHERE t.project_id = p.id
|
WHERE t.project_id = p.id
|
||||||
AND t.time > NOW() - INTERVAL '10 minutes'
|
AND CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1
|
||||||
|
AND t.time > NOW() - ($2::text)::interval
|
||||||
)
|
)
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
||||||
)
|
)
|
||||||
"#,
|
"#
|
||||||
)
|
}
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for row in rows {
|
pub(crate) fn telemetry_silence_recent_alarm_sql() -> &'static str {
|
||||||
let proj_id: Uuid = row.get("id");
|
r#"
|
||||||
let proj_name: String = row.get("name");
|
|
||||||
let msg = format!(
|
|
||||||
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
|
|
||||||
proj_name
|
|
||||||
);
|
|
||||||
|
|
||||||
let already_alerted = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT 1 FROM telemetry_alarms
|
SELECT 1 FROM telemetry_alarms
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND alarm_type = 'TELEMETRY_SILENCE'
|
AND (
|
||||||
|
alarm_type = $2
|
||||||
|
OR ($2 = 'TELEMETRY_SILENCE_HF' AND alarm_type = 'TELEMETRY_SILENCE')
|
||||||
|
)
|
||||||
AND timestamp > NOW() - INTERVAL '1 hour'
|
AND timestamp > NOW() - INTERVAL '1 hour'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#
|
||||||
)
|
|
||||||
.bind(proj_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if already_alerted.is_none() {
|
|
||||||
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
|
||||||
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
|
|
||||||
)
|
|
||||||
.bind(proj_id)
|
|
||||||
.bind(&msg)
|
|
||||||
.bind(Utc::now())
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let alarm = AlarmNotification {
|
|
||||||
project_id: proj_id,
|
|
||||||
asset_id: None,
|
|
||||||
variable: "TELEMETRY_SILENCE".to_string(),
|
|
||||||
value: 0.0,
|
|
||||||
alarm_type: "TELEMETRY_SILENCE".to_string(),
|
|
||||||
message: msg,
|
|
||||||
timestamp: Utc::now(),
|
|
||||||
};
|
|
||||||
notifier::dispatch(pool, &alarm, tx).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_unhealthy_agents(
|
async fn check_unhealthy_agents(
|
||||||
@@ -430,6 +501,36 @@ mod tests {
|
|||||||
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
|
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
|
||||||
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
|
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_silence_select_uses_source_derived_frequency_classes() {
|
||||||
|
let sql = telemetry_silence_select_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("FROM telemetry_raw t"));
|
||||||
|
assert!(sql.contains("CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1"));
|
||||||
|
assert!(sql.contains("t.time > NOW() - ($2::text)::interval"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_silence_recent_alarm_maps_legacy_silence_to_hf() {
|
||||||
|
let sql = telemetry_silence_recent_alarm_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("alarm_type = $2"));
|
||||||
|
assert!(sql.contains("$2 = 'TELEMETRY_SILENCE_HF'"));
|
||||||
|
assert!(sql.contains("alarm_type = 'TELEMETRY_SILENCE'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_silence_defaults_are_hf_ten_minutes_and_lf_twenty_four_hours() {
|
||||||
|
let specs = telemetry_silence_specs_from_values(None, None);
|
||||||
|
|
||||||
|
assert_eq!(specs[0].frequency_class, "HF");
|
||||||
|
assert_eq!(specs[0].alarm_type, TELEMETRY_SILENCE_HF);
|
||||||
|
assert_eq!(format_window_interval(specs[0].window), "600 seconds");
|
||||||
|
assert_eq!(specs[1].frequency_class, "LF");
|
||||||
|
assert_eq!(specs[1].alarm_type, TELEMETRY_SILENCE_LF);
|
||||||
|
assert_eq!(format_window_interval(specs[1].window), "86400 seconds");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_calibration_expirations(
|
async fn check_calibration_expirations(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin data-collector
|
|||||||
# 3. BUILDER
|
# 3. BUILDER
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY . .
|
# Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
|
||||||
# Copiar caché del cacher
|
COPY --from=cacher /app /app
|
||||||
COPY --from=cacher /app/target target
|
|
||||||
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
COPY --from=cacher /usr/local/cargo /usr/local/cargo
|
||||||
|
|
||||||
|
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
|
COPY services/data-collector services/data-collector
|
||||||
RUN cargo build --release --bin data-collector
|
RUN cargo build --release --bin data-collector
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -23,23 +23,25 @@ pub async fn ingest_telemetry_batch(
|
|||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
// 2. Resolver Asset ID por Código (Cacheable en el futuro)
|
// 2. Resolver Asset ID por Código (Cacheable en el futuro)
|
||||||
let asset_opt = sqlx::query("SELECT id FROM edge_assets WHERE code = $1")
|
let asset_opt = sqlx::query("SELECT id, project_id FROM edge_assets WHERE code = $1")
|
||||||
.bind(asset_code)
|
.bind(asset_code)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(asset) = asset_opt {
|
if let Some(asset) = asset_opt {
|
||||||
let asset_id: uuid::Uuid = asset.get("id");
|
let asset_id: uuid::Uuid = asset.get("id");
|
||||||
|
let project_id: uuid::Uuid = asset.get("project_id");
|
||||||
|
|
||||||
// 3. Insertar Telemetría Raw
|
// 3. Insertar Telemetría Raw
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO telemetry_raw (time, asset_id, data)
|
INSERT INTO telemetry_raw (time, project_id, asset_id, data, source)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4, 'SCADA')
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING
|
ON CONFLICT (time, asset_id) DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&time)
|
.bind(&time)
|
||||||
|
.bind(&project_id)
|
||||||
.bind(&asset_id)
|
.bind(&asset_id)
|
||||||
.bind(&data)
|
.bind(&data)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
|
|||||||
@@ -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) && \
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use tokio::time::{Duration, interval};
|
|||||||
/// Estado compartido para el gateway de datos.
|
/// Estado compartido para el gateway de datos.
|
||||||
struct GatewayState {
|
struct GatewayState {
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
|
project_id: uuid::Uuid,
|
||||||
telemetry_topic: String,
|
telemetry_topic: String,
|
||||||
storage: Arc<StoreAndForward>,
|
storage: Arc<StoreAndForward>,
|
||||||
last_heartbeat: Arc<AtomicI64>,
|
last_heartbeat: Arc<AtomicI64>,
|
||||||
@@ -30,6 +31,7 @@ pub async fn start_data_gateway(
|
|||||||
|
|
||||||
let state = Arc::new(GatewayState {
|
let state = Arc::new(GatewayState {
|
||||||
mqtt_client: mqtt_client.clone(),
|
mqtt_client: mqtt_client.clone(),
|
||||||
|
project_id,
|
||||||
telemetry_topic: format!("omnioil/telemetry/{}", project_id),
|
telemetry_topic: format!("omnioil/telemetry/{}", project_id),
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
last_heartbeat: last_heartbeat.clone(),
|
last_heartbeat: last_heartbeat.clone(),
|
||||||
@@ -70,16 +72,13 @@ async fn ingest_data(
|
|||||||
) -> Json<Value> {
|
) -> Json<Value> {
|
||||||
let topic = &state.telemetry_topic;
|
let topic = &state.telemetry_topic;
|
||||||
|
|
||||||
// Asegurar que el payload tenga un timestamp de captura (Capture Time)
|
if let Err(e) = normalize_telemetry_payload(&mut payload, state.project_id) {
|
||||||
// Si Node-RED no lo envió, lo generamos en el momento justo de recibirlo en el borde.
|
tracing::warn!(
|
||||||
if payload.get("timestamp").is_none() {
|
project_id = %state.project_id,
|
||||||
if let Some(obj) = payload.as_object_mut() {
|
error = %e,
|
||||||
obj.insert(
|
"Data Gateway rejected invalid SCADA telemetry payload"
|
||||||
"timestamp".to_string(),
|
);
|
||||||
json!(chrono::Utc::now().to_rfc3339()),
|
return Json(json!({ "status": "error", "message": e }));
|
||||||
);
|
|
||||||
obj.insert("is_agent_timestamped".to_string(), json!(true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let data_str = match serde_json::to_string(&payload) {
|
let data_str = match serde_json::to_string(&payload) {
|
||||||
@@ -122,7 +121,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 +131,7 @@ async fn ingest_data(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Timeout (Buffer lleno o red colgada)
|
// Timeout (Buffer lleno o red colgada)
|
||||||
tracing::warn!("⏳ MQTT Timeout. Saving to local storage to unblock.");
|
tracing::warn!("⏳ MQTT Timeout. Saving to local storage to unblock.");
|
||||||
if let Err(err) = state.storage.save(topic, &data_str) {
|
if let Err(err) = state.storage.save(topic, &data_str).await {
|
||||||
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
||||||
Json(json!({ "status": "error", "message": "Storage failure" }))
|
Json(json!({ "status": "error", "message": "Storage failure" }))
|
||||||
} else {
|
} else {
|
||||||
@@ -142,6 +141,66 @@ async fn ingest_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_telemetry_payload(
|
||||||
|
payload: &mut Value,
|
||||||
|
authoritative_project_id: uuid::Uuid,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let obj = payload
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| "payload must be a JSON object".to_string())?;
|
||||||
|
|
||||||
|
match obj.get("project_id") {
|
||||||
|
Some(Value::String(project_id)) => {
|
||||||
|
let payload_project_id = uuid::Uuid::parse_str(project_id)
|
||||||
|
.map_err(|_| "project_id must be a valid UUID string".to_string())?;
|
||||||
|
|
||||||
|
if payload_project_id != authoritative_project_id {
|
||||||
|
return Err(format!(
|
||||||
|
"project_id mismatch: payload project_id {} does not match gateway project_id {}",
|
||||||
|
payload_project_id, authoritative_project_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => return Err("project_id must be a UUID string when present".to_string()),
|
||||||
|
None => {
|
||||||
|
obj.insert(
|
||||||
|
"project_id".to_string(),
|
||||||
|
json!(authoritative_project_id.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match obj.get("device_name") {
|
||||||
|
Some(Value::String(device_name)) if !device_name.trim().is_empty() => {}
|
||||||
|
Some(Value::String(_)) => return Err("device_name must not be empty".to_string()),
|
||||||
|
Some(_) => return Err("device_name must be a string".to_string()),
|
||||||
|
None => return Err("device_name is required".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
match obj.get("values") {
|
||||||
|
Some(Value::Object(_)) => {}
|
||||||
|
Some(_) => return Err("values must be a JSON object".to_string()),
|
||||||
|
None => return Err("values is required".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
match obj.get("timestamp") {
|
||||||
|
Some(Value::String(timestamp)) => {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(timestamp)
|
||||||
|
.map_err(|_| "timestamp must be a valid RFC3339 datetime string".to_string())?;
|
||||||
|
}
|
||||||
|
Some(_) => return Err("timestamp must be a string".to_string()),
|
||||||
|
None => {
|
||||||
|
obj.insert(
|
||||||
|
"timestamp".to_string(),
|
||||||
|
json!(chrono::Utc::now().to_rfc3339()),
|
||||||
|
);
|
||||||
|
obj.insert("is_agent_timestamped".to_string(), json!(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Handler para watchdog (Heartbeat desde el contenedor).
|
/// Handler para watchdog (Heartbeat desde el contenedor).
|
||||||
async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value> {
|
async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value> {
|
||||||
state
|
state
|
||||||
@@ -149,3 +208,105 @@ async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value>
|
|||||||
.store(chrono::Utc::now().timestamp(), Ordering::SeqCst);
|
.store(chrono::Utc::now().timestamp(), Ordering::SeqCst);
|
||||||
Json(json!({ "status": "alive" }))
|
Json(json!({ "status": "alive" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_adds_authoritative_project_and_agent_timestamp_when_missing() {
|
||||||
|
let project_id = uuid::Uuid::new_v4();
|
||||||
|
let mut payload = json!({
|
||||||
|
"device_name": "node-red-pump-1",
|
||||||
|
"values": { "pressure": 42.0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
normalize_telemetry_payload(&mut payload, project_id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload["project_id"], project_id.to_string());
|
||||||
|
assert_eq!(payload["device_name"], "node-red-pump-1");
|
||||||
|
assert_eq!(payload["values"]["pressure"], 42.0);
|
||||||
|
assert!(payload.get("timestamp").and_then(Value::as_str).is_some());
|
||||||
|
assert_eq!(payload["is_agent_timestamped"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_rejects_payload_project_mismatch() {
|
||||||
|
let project_id = uuid::Uuid::new_v4();
|
||||||
|
let mut payload = json!({
|
||||||
|
"project_id": uuid::Uuid::new_v4().to_string(),
|
||||||
|
"device_name": "node-red-pump-1",
|
||||||
|
"values": { "pressure": 42.0 },
|
||||||
|
"timestamp": "2026-06-27T00:00:00Z"
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = normalize_telemetry_payload(&mut payload, project_id).unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.contains("project_id mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_requires_device_name_and_values_object() {
|
||||||
|
let project_id = uuid::Uuid::new_v4();
|
||||||
|
|
||||||
|
let mut missing_device = json!({ "values": { "pressure": 42.0 } });
|
||||||
|
assert_eq!(
|
||||||
|
normalize_telemetry_payload(&mut missing_device, project_id).unwrap_err(),
|
||||||
|
"device_name is required"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut invalid_values = json!({
|
||||||
|
"device_name": "node-red-pump-1",
|
||||||
|
"values": 42.0
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
normalize_telemetry_payload(&mut invalid_values, project_id).unwrap_err(),
|
||||||
|
"values must be a JSON object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_preserves_existing_timestamp_without_agent_flag() {
|
||||||
|
let project_id = uuid::Uuid::new_v4();
|
||||||
|
let timestamp = "2026-06-27T00:00:00Z";
|
||||||
|
let mut payload = json!({
|
||||||
|
"project_id": project_id.to_string(),
|
||||||
|
"device_name": "node-red-pump-1",
|
||||||
|
"values": { "pressure": 42.0 },
|
||||||
|
"timestamp": timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
normalize_telemetry_payload(&mut payload, project_id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload["timestamp"], timestamp);
|
||||||
|
assert!(payload.get("is_agent_timestamped").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_rejects_invalid_timestamp() {
|
||||||
|
let project_id = uuid::Uuid::new_v4();
|
||||||
|
let mut payload = json!({
|
||||||
|
"project_id": project_id.to_string(),
|
||||||
|
"device_name": "node-red-pump-1",
|
||||||
|
"values": { "pressure": 42.0 },
|
||||||
|
"timestamp": 123
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
normalize_telemetry_payload(&mut payload, project_id).unwrap_err(),
|
||||||
|
"timestamp must be a string"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut payload = json!({
|
||||||
|
"project_id": project_id.to_string(),
|
||||||
|
"device_name": "node-red-pump-1",
|
||||||
|
"values": { "pressure": 42.0 },
|
||||||
|
"timestamp": "not-a-date"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
normalize_telemetry_payload(&mut payload, project_id).unwrap_err(),
|
||||||
|
"timestamp must be a valid RFC3339 datetime string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,13 +258,13 @@ pub async fn run_agent(
|
|||||||
let queue_storage = storage.clone();
|
let queue_storage = storage.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Limpieza inmediata al arrancar
|
// Limpieza inmediata al arrancar
|
||||||
let _ = queue_storage.cleanup_old_queue_entries(7);
|
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 +276,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 +350,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 {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -129,15 +129,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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ export interface AlarmEvent {
|
|||||||
current_value?: number | null
|
current_value?: number | null
|
||||||
alarm_min?: number | null
|
alarm_min?: number | null
|
||||||
alarm_max?: number | null
|
alarm_max?: number | null
|
||||||
alarm_type: 'ABOVE_MAX' | 'BELOW_MIN' | 'TELEMETRY_SILENCE' | 'AGENT_OFFLINE' | string
|
alarm_type:
|
||||||
|
| 'ABOVE_MAX'
|
||||||
|
| 'BELOW_MIN'
|
||||||
|
| 'TELEMETRY_SILENCE'
|
||||||
|
| 'TELEMETRY_SILENCE_HF'
|
||||||
|
| 'TELEMETRY_SILENCE_LF'
|
||||||
|
| 'AGENT_OFFLINE'
|
||||||
|
| string
|
||||||
message?: string | null
|
message?: string | null
|
||||||
acknowledged?: boolean | null
|
acknowledged?: boolean | null
|
||||||
acknowledged_at?: string | null
|
acknowledged_at?: string | null
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function DashboardOperationalHeader({
|
|||||||
const realAssetOptions = assetOptions.filter((option) => option.value !== 'all')
|
const realAssetOptions = assetOptions.filter((option) => option.value !== 'all')
|
||||||
const shouldShowAssetSelect = realAssetOptions.length > 0 && assetOptions.length >= 2
|
const shouldShowAssetSelect = realAssetOptions.length > 0 && assetOptions.length >= 2
|
||||||
const assetStatusLabel = hasResolvedProject
|
const assetStatusLabel = hasResolvedProject
|
||||||
? realAssetOptions[0]?.label ?? 'Sin pozos configurados'
|
? 'Sin pozos configurados'
|
||||||
: 'Seleccioná un proyecto'
|
: 'Seleccioná un proyecto'
|
||||||
const statusTone = status.label === 'Operacional'
|
const statusTone = status.label === 'Operacional'
|
||||||
? 'border-[#27d796]/30 bg-[#27d796]/10'
|
? 'border-[#27d796]/30 bg-[#27d796]/10'
|
||||||
@@ -62,10 +62,10 @@ export function DashboardOperationalHeader({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">Activo</label>
|
<label className="text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">Alcance</label>
|
||||||
{shouldShowAssetSelect ? (
|
{shouldShowAssetSelect ? (
|
||||||
<Select
|
<Select
|
||||||
aria-label="Activo"
|
aria-label="Alcance de pozos"
|
||||||
className="min-w-40"
|
className="min-w-40"
|
||||||
triggerClassName="bg-[#090b0f] border-[#2a3038] text-sm"
|
triggerClassName="bg-[#090b0f] border-[#2a3038] text-sm"
|
||||||
options={assetOptions}
|
options={assetOptions}
|
||||||
@@ -75,7 +75,7 @@ export function DashboardOperationalHeader({
|
|||||||
) : (
|
) : (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="min-w-40 justify-center border-[#2a3038] bg-[#090b0f] px-3 py-2 text-sm font-medium text-muted-foreground"
|
className="min-w-40 justify-center px-3 py-2 text-sm font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{assetStatusLabel}
|
{assetStatusLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ interface DashboardPanoramaLowerSectionProps {
|
|||||||
wells: WellOverviewCard[]
|
wells: WellOverviewCard[]
|
||||||
alerts: RecentAlertItem[]
|
alerts: RecentAlertItem[]
|
||||||
categoryHealth: VariableCategoryHealthItem[]
|
categoryHealth: VariableCategoryHealthItem[]
|
||||||
|
overviewTitle: string
|
||||||
|
overviewEmptyTitle: string
|
||||||
|
overviewEmptyDescription: string
|
||||||
|
alertsTitle: string
|
||||||
|
alertsEmptyTitle: string
|
||||||
|
alertsEmptyDescription: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSparklinePath(values: number[]): string {
|
function buildSparklinePath(values: number[]): string {
|
||||||
@@ -169,21 +175,31 @@ function CategoryHealthPanel({ items }: { items: VariableCategoryHealthItem[] })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPanoramaLowerSection({ wells, alerts, categoryHealth }: DashboardPanoramaLowerSectionProps) {
|
export function DashboardPanoramaLowerSection({
|
||||||
|
wells,
|
||||||
|
alerts,
|
||||||
|
categoryHealth,
|
||||||
|
overviewTitle,
|
||||||
|
overviewEmptyTitle,
|
||||||
|
overviewEmptyDescription,
|
||||||
|
alertsTitle,
|
||||||
|
alertsEmptyTitle,
|
||||||
|
alertsEmptyDescription,
|
||||||
|
}: DashboardPanoramaLowerSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Panorama por pozo y alertas recientes" className="grid gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(320px,0.85fr)]">
|
<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="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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Panorama</p>
|
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Panorama</p>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Vista general por pozo</h2>
|
<h2 className="text-sm font-semibold text-foreground">{overviewTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Gauge className="size-4 text-[#00b7ff]" />
|
<Gauge className="size-4 text-[#00b7ff]" />
|
||||||
</div>
|
</div>
|
||||||
{wells.length === 0 ? (
|
{wells.length === 0 ? (
|
||||||
<div className="rounded-[1.4rem] border border-dashed border-[#2a3038] bg-[#171b20] p-6 text-center">
|
<div className="rounded-[1.4rem] border border-dashed border-[#2a3038] bg-[#171b20] p-6 text-center">
|
||||||
<p className="text-sm font-semibold text-foreground">Sin pozos cargados</p>
|
<p className="text-sm font-semibold text-foreground">{overviewEmptyTitle}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">No hay activos POZO disponibles para el proyecto seleccionado.</p>
|
<p className="mt-1 text-xs text-muted-foreground">{overviewEmptyDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
@@ -193,11 +209,11 @@ export function DashboardPanoramaLowerSection({ wells, alerts, categoryHealth }:
|
|||||||
<CategoryHealthPanel items={categoryHealth} />
|
<CategoryHealthPanel items={categoryHealth} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="space-y-4 rounded-[1.75rem] border border-[#2a3038] bg-[#15191e] p-4 shadow-2xl shadow-black/20" aria-label="Alertas recientes">
|
<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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Alarmas</p>
|
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-[#9aa8ba]">Alarmas</p>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Alertas recientes</h2>
|
<h2 className="text-sm font-semibold text-foreground">{alertsTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[#ffb020]/10 px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-[#ffb020]">
|
<span className="rounded-full bg-[#ffb020]/10 px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-[#ffb020]">
|
||||||
{alerts.length}
|
{alerts.length}
|
||||||
@@ -205,8 +221,8 @@ export function DashboardPanoramaLowerSection({ wells, alerts, categoryHealth }:
|
|||||||
</div>
|
</div>
|
||||||
{alerts.length === 0 ? (
|
{alerts.length === 0 ? (
|
||||||
<div className="rounded-[1.4rem] border border-dashed border-[#2a3038] bg-[#171b20] p-6 text-center">
|
<div className="rounded-[1.4rem] border border-dashed border-[#2a3038] bg-[#171b20] p-6 text-center">
|
||||||
<p className="text-sm font-semibold text-foreground">Sin alertas recientes</p>
|
<p className="text-sm font-semibold text-foreground">{alertsEmptyTitle}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">No hay alarmas reales para mostrar en este momento.</p>
|
<p className="mt-1 text-xs text-muted-foreground">{alertsEmptyDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe('DashboardPage well selector', () => {
|
|||||||
})
|
})
|
||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
expect(container.textContent).toContain('Activo')
|
expect(container.textContent).toContain('Alcance')
|
||||||
expect(container.textContent).toContain('Sin pozos configurados')
|
expect(container.textContent).toContain('Sin pozos configurados')
|
||||||
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -195,7 +195,6 @@ describe('DashboardPage well selector', () => {
|
|||||||
await flushEffects()
|
await flushEffects()
|
||||||
|
|
||||||
expect(listAssets).not.toHaveBeenCalled()
|
expect(listAssets).not.toHaveBeenCalled()
|
||||||
expect(getAlarms).not.toHaveBeenCalled()
|
|
||||||
expect(container.textContent).toContain('Seleccioná un proyecto')
|
expect(container.textContent).toContain('Seleccioná un proyecto')
|
||||||
expect(container.textContent).not.toContain('Sin pozos configurados')
|
expect(container.textContent).not.toContain('Sin pozos configurados')
|
||||||
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
expect(container.querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
||||||
@@ -487,6 +486,125 @@ describe('DashboardPage well selector', () => {
|
|||||||
expect(container.textContent).toContain('Producción—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 () => {
|
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(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([
|
vi.mocked(listVariables).mockResolvedValue([
|
||||||
@@ -661,12 +779,12 @@ describe('dashboard view-model helpers', () => {
|
|||||||
acknowledged: true,
|
acknowledged: true,
|
||||||
timestamp: '2026-06-22T11:00:00Z',
|
timestamp: '2026-06-22T11:00:00Z',
|
||||||
},
|
},
|
||||||
])
|
], 4)
|
||||||
expect(summary.connectedWellCount).toBe(2)
|
expect(summary.connectedWellCount).toBe(2)
|
||||||
expect(summary.signalCount).toBe(3)
|
expect(summary.signalCount).toBe(3)
|
||||||
expect(summary.metrics.map(({ icon: _icon, ...metric }) => metric)).toEqual([
|
expect(summary.metrics.map(({ icon: _icon, ...metric }) => metric)).toEqual([
|
||||||
{ label: 'Pozos', value: 2, sub: 'en operación' },
|
{ label: 'Pozos', value: 2, sub: 'en operación' },
|
||||||
{ label: 'Variables', value: 3, sub: 'monitoreadas' },
|
{ label: 'Variables', value: 4, sub: 'monitoreadas' },
|
||||||
{ label: 'Alertas', value: 1, sub: 'activas', accent: 'text-[#ffb020]' },
|
{ label: 'Alertas', value: 1, sub: 'activas', accent: 'text-[#ffb020]' },
|
||||||
{ label: 'Alta frec.', value: null, sub: 'sin fuente configurada', accent: 'text-[#00b7ff]' },
|
{ 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: 'Baja frec.', value: null, sub: 'sin fuente configurada', accent: 'text-[#27d796]' },
|
||||||
@@ -943,7 +1061,7 @@ describe('dashboard presentational components', () => {
|
|||||||
expect(container.textContent).toContain('Telemetría de Pozos')
|
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('Telemetría activa · 1 pozo conectado · 7 muestras hoy · latencia 17 ms')
|
||||||
expect(container.textContent).toContain('Enlace SCADA estable')
|
expect(container.textContent).toContain('Enlace SCADA estable')
|
||||||
expect(container.textContent).toContain('Activo')
|
expect(container.textContent).toContain('Alcance')
|
||||||
expect(container.querySelector('button[aria-haspopup="listbox"]')?.textContent).toContain('Todos los pozos')
|
expect(container.querySelector('button[aria-haspopup="listbox"]')?.textContent).toContain('Todos los pozos')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -982,7 +1100,6 @@ describe('dashboard presentational components', () => {
|
|||||||
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
|
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
|
||||||
complianceStatus={{ samplesToday: 0, latencyMs: 0 }}
|
complianceStatus={{ samplesToday: 0, latencyMs: 0 }}
|
||||||
assetOptions={[{ value: 'all', label: 'Todos los pozos' }]}
|
assetOptions={[{ value: 'all', label: 'Todos los pozos' }]}
|
||||||
connectedWellCount={0}
|
|
||||||
selectedAsset="all"
|
selectedAsset="all"
|
||||||
setSelectedAsset={setSelectedAsset}
|
setSelectedAsset={setSelectedAsset}
|
||||||
hasResolvedProject
|
hasResolvedProject
|
||||||
@@ -1003,7 +1120,6 @@ describe('dashboard presentational components', () => {
|
|||||||
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
|
status={{ color: 'text-[#27d796]', label: 'Operacional' }}
|
||||||
complianceStatus={{ samplesToday: 0, latencyMs: 0 }}
|
complianceStatus={{ samplesToday: 0, latencyMs: 0 }}
|
||||||
assetOptions={[{ value: 'all', label: 'Todos los pozos' }]}
|
assetOptions={[{ value: 'all', label: 'Todos los pozos' }]}
|
||||||
connectedWellCount={0}
|
|
||||||
selectedAsset="all"
|
selectedAsset="all"
|
||||||
setSelectedAsset={setSelectedAsset}
|
setSelectedAsset={setSelectedAsset}
|
||||||
hasResolvedProject={false}
|
hasResolvedProject={false}
|
||||||
|
|||||||
@@ -209,11 +209,41 @@ export default function DashboardPage() {
|
|||||||
.catch(err => console.error('History error:', err))
|
.catch(err => console.error('History error:', err))
|
||||||
}, [selectedAsset]);
|
}, [selectedAsset]);
|
||||||
|
|
||||||
const activeAssetData = useMemo(() => {
|
const selectedWell = useMemo(() => {
|
||||||
if (selectedAsset === 'all') return assets[0] || null
|
if (selectedAsset === 'all') return null
|
||||||
return assets.find(a => a.asset_id === selectedAsset) || null
|
return wellAssets.find((asset) => asset.id === selectedAsset) ?? null
|
||||||
|
}, [selectedAsset, wellAssets])
|
||||||
|
|
||||||
|
const selectedWellLabel = useMemo(() => {
|
||||||
|
if (!selectedWell) return null
|
||||||
|
return selectedWell.name || selectedWell.code || selectedWell.id.slice(0, 8)
|
||||||
|
}, [selectedWell])
|
||||||
|
|
||||||
|
const scopedWellAssets = useMemo(() => {
|
||||||
|
if (selectedAsset === 'all') return wellAssets
|
||||||
|
return selectedWell ? [selectedWell] : []
|
||||||
|
}, [selectedAsset, selectedWell, wellAssets])
|
||||||
|
|
||||||
|
const scopedAssets = useMemo(() => {
|
||||||
|
if (selectedAsset === 'all') return assets
|
||||||
|
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(() => {
|
||||||
return buildDynamicStats(activeAssetData)
|
return buildDynamicStats(activeAssetData)
|
||||||
}, [activeAssetData])
|
}, [activeAssetData])
|
||||||
@@ -235,17 +265,48 @@ export default function DashboardPage() {
|
|||||||
}, [isConnected, complianceStatus.isStable])
|
}, [isConnected, complianceStatus.isStable])
|
||||||
|
|
||||||
const assetOptions = useMemo(() => buildAssetOptions(wellAssets), [wellAssets])
|
const assetOptions = useMemo(() => buildAssetOptions(wellAssets), [wellAssets])
|
||||||
const connectedWellCount = useMemo(() => countConnectedWells(assets, wellAssets), [assets, wellAssets])
|
const connectedWellCount = useMemo(() => (
|
||||||
const panoramaSummary = useMemo(() => buildPanoramaSummary(assets, wellAssets, alarms), [assets, wellAssets, alarms])
|
countConnectedWells(scopedAssets, scopedWellAssets)
|
||||||
|
), [scopedAssets, scopedWellAssets])
|
||||||
const wellOverviewCards = useMemo(() => (
|
const wellOverviewCards = useMemo(() => (
|
||||||
buildWellOverviewCards(wellAssets, assets, alarms, history, selectedAsset, resolvedProjectName)
|
buildWellOverviewCards(scopedWellAssets, scopedAssets, scopedAlarms, history, selectedAsset, resolvedProjectName)
|
||||||
), [wellAssets, assets, alarms, 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(() => (
|
const variableCategoryHealth = useMemo(() => (
|
||||||
buildVariableCategoryHealth(variableCatalog, assets, alarms, wellAssets)
|
buildVariableCategoryHealth(scopedVariableCatalog, scopedAssets, scopedAlarms, scopedWellAssets)
|
||||||
), [variableCatalog, assets, alarms, wellAssets])
|
), [scopedVariableCatalog, scopedAssets, scopedAlarms, scopedWellAssets])
|
||||||
const recentAlerts = useMemo(() => (
|
const recentAlerts = useMemo(() => (
|
||||||
buildRecentAlerts(alarms, wellAssets, resolvedProjectName)
|
buildRecentAlerts(scopedAlarms, scopedWellAssets, resolvedProjectName)
|
||||||
), [alarms, wellAssets, resolvedProjectName])
|
), [scopedAlarms, scopedWellAssets, resolvedProjectName])
|
||||||
|
|
||||||
|
const lowerSectionLabels = useMemo(() => {
|
||||||
|
if (selectedAsset === 'all') {
|
||||||
|
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">
|
||||||
@@ -271,6 +332,7 @@ export default function DashboardPage() {
|
|||||||
wells={wellOverviewCards}
|
wells={wellOverviewCards}
|
||||||
alerts={recentAlerts}
|
alerts={recentAlerts}
|
||||||
categoryHealth={variableCategoryHealth}
|
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">
|
<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">
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { AlertTriangle, RotateCcw, Search, SlidersHorizontal, Table2 } from 'lucide-react'
|
import { AlertTriangle, RotateCcw, Search, SlidersHorizontal, Table2 } from 'lucide-react'
|
||||||
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryResponse } from '../api/dashboard-api'
|
import { fetchAssetTelemetryHistory, fetchDashboardSummary, type TelemetryHistoryResponse } from '../api/dashboard-api'
|
||||||
@@ -41,6 +41,16 @@ const defaultFilters: VariableExplorerFilters = {
|
|||||||
status: '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'>) {
|
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'))
|
return Array.from(new Set(rows.map((row) => row[key]).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'es'))
|
||||||
}
|
}
|
||||||
@@ -48,13 +58,12 @@ function uniqueOptions(rows: VariableExplorerRow[], key: keyof Pick<VariableExpl
|
|||||||
export function VariablesView() {
|
export function VariablesView() {
|
||||||
const activeProjectId = useAuthStore((state) => state.activeProjectId)
|
const activeProjectId = useAuthStore((state) => state.activeProjectId)
|
||||||
const projects = useAuthStore((state) => state.projects)
|
const projects = useAuthStore((state) => state.projects)
|
||||||
const [config, setConfig] = useState<EdgeProjectFullConfig | null>(null)
|
const [snapshot, setSnapshot] = useState<VariablesSnapshot | null>(null)
|
||||||
const [telemetry, setTelemetry] = useState<AssetData[]>([])
|
const [loadState, setLoadState] = useState<VariablesLoadState>('idle')
|
||||||
const [historyByAssetId, setHistoryByAssetId] = useState<Record<string, TelemetryHistoryResponse | null>>({})
|
|
||||||
const [alarms, setAlarms] = useState<AlarmEvent[]>([])
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [filters, setFilters] = useState<VariableExplorerFilters>(defaultFilters)
|
const [filters, setFilters] = useState<VariableExplorerFilters>(defaultFilters)
|
||||||
|
const latestRequestIdRef = useRef(0)
|
||||||
|
const snapshotRef = useRef<VariablesSnapshot | null>(null)
|
||||||
|
|
||||||
const resolvedProjectId = useMemo(() => {
|
const resolvedProjectId = useMemo(() => {
|
||||||
if (activeProjectId) return activeProjectId
|
if (activeProjectId) return activeProjectId
|
||||||
@@ -66,22 +75,30 @@ export function VariablesView() {
|
|||||||
|
|
||||||
async function loadVariables(showLoading = true) {
|
async function loadVariables(showLoading = true) {
|
||||||
if (!resolvedProjectId) {
|
if (!resolvedProjectId) {
|
||||||
setConfig(null)
|
snapshotRef.current = null
|
||||||
setTelemetry([])
|
setSnapshot(null)
|
||||||
setHistoryByAssetId({})
|
setLoadState('idle')
|
||||||
setAlarms([])
|
setError(null)
|
||||||
setIsLoading(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showLoading) setIsLoading(true)
|
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)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [fullConfig, summary, activeAlarms] = await Promise.all([
|
const [fullConfig, summary, activeAlarms] = await Promise.all([
|
||||||
getProjectFullConfig(resolvedProjectId),
|
getProjectFullConfig(requestProjectId),
|
||||||
fetchDashboardSummary(),
|
fetchDashboardSummary(),
|
||||||
getAlarms(resolvedProjectId),
|
getAlarms(requestProjectId),
|
||||||
])
|
])
|
||||||
const wellAssetIds = fullConfig.assets
|
const wellAssetIds = fullConfig.assets
|
||||||
.filter(({ asset }) => asset.asset_type === 'POZO')
|
.filter(({ asset }) => asset.asset_type === 'POZO')
|
||||||
@@ -96,21 +113,24 @@ export function VariablesView() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (!mounted) return
|
if (!mounted || latestRequestIdRef.current !== requestId) return
|
||||||
setConfig(fullConfig)
|
const nextSnapshot: VariablesSnapshot = {
|
||||||
setTelemetry(summary.data || [])
|
projectId: requestProjectId,
|
||||||
setHistoryByAssetId(Object.fromEntries(historyEntries))
|
config: fullConfig,
|
||||||
setAlarms(activeAlarms)
|
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) {
|
} catch (loadError) {
|
||||||
if (!mounted) return
|
if (!mounted || latestRequestIdRef.current !== requestId) return
|
||||||
console.error('Variables view error:', loadError)
|
void loadError
|
||||||
setConfig(null)
|
|
||||||
setTelemetry([])
|
|
||||||
setHistoryByAssetId({})
|
|
||||||
setAlarms([])
|
|
||||||
setError('No se pudo cargar la configuración real de variables.')
|
setError('No se pudo cargar la configuración real de variables.')
|
||||||
} finally {
|
setLoadState('error')
|
||||||
if (mounted) setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,11 +143,20 @@ export function VariablesView() {
|
|||||||
}
|
}
|
||||||
}, [resolvedProjectId])
|
}, [resolvedProjectId])
|
||||||
|
|
||||||
const rows = useMemo(() => buildVariableExplorerRows(config, telemetry, alarms), [config, telemetry, alarms])
|
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 filteredRows = useMemo(() => filterVariableExplorerRows(rows, filters), [rows, filters])
|
||||||
const wellOptions = useMemo(() => uniqueOptions(rows, 'well'), [rows])
|
const wellOptions = useMemo(() => uniqueOptions(rows, 'well'), [rows])
|
||||||
const categoryOptions = useMemo(() => uniqueOptions(rows, 'category'), [rows])
|
const categoryOptions = useMemo(() => uniqueOptions(rows, 'category'), [rows])
|
||||||
const frequencyOptions = useMemo(() => uniqueOptions(rows, 'frequency'), [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) => {
|
const setFilter = (key: keyof VariableExplorerFilters, value: string) => {
|
||||||
setFilters((current) => ({ ...current, [key]: value }))
|
setFilters((current) => ({ ...current, [key]: value }))
|
||||||
@@ -151,10 +180,17 @@ export function VariablesView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-[#2a3038] bg-[#11151b]/90 px-4 py-2 text-right shadow-lg shadow-black/20">
|
<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-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Resultado</p>
|
||||||
<p className="text-lg font-semibold tabular-nums text-white">{filteredRows.length} de {rows.length} variables</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>
|
||||||
</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">
|
<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="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">
|
<div className="inline-flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-cyan-100">
|
||||||
@@ -193,13 +229,13 @@ export function VariablesView() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isLoading ? (
|
{isInitialLoading ? (
|
||||||
<VariablesMessage title="Cargando variables reales" description="Consultando configuración, telemetría y alarmas del proyecto." />
|
<VariablesMessage title="Cargando variables reales" description="Consultando configuración, telemetría y alarmas del proyecto." />
|
||||||
) : error ? (
|
) : 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} />
|
<VariablesMessage icon={<AlertTriangle className="h-5 w-5 text-amber-300" />} title="No se pudo cargar la vista" description={error} />
|
||||||
) : !resolvedProjectId ? (
|
) : !resolvedProjectId ? (
|
||||||
<VariablesMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver sus variables configuradas." />
|
<VariablesMessage title="Sin proyecto activo" description="Seleccioná un proyecto para ver sus variables configuradas." />
|
||||||
) : rows.length === 0 ? (
|
) : loadState === 'empty' && rows.length === 0 ? (
|
||||||
<VariablesMessage title="Sin variables reales" description="No hay variables configuradas en assets de tipo POZO para este proyecto." />
|
<VariablesMessage title="Sin variables reales" description="No hay variables configuradas en assets de tipo POZO para este proyecto." />
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<VariablesMessage title="Sin coincidencias" description="Los filtros actuales no coinciden con variables reales del proyecto." />
|
<VariablesMessage title="Sin coincidencias" description="Los filtros actuales no coinciden con variables reales del proyecto." />
|
||||||
@@ -222,7 +258,7 @@ export function VariablesView() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRows.map((row) => {
|
{filteredRows.map((row) => {
|
||||||
const trendPoints = buildTrendSeries(historyByAssetId[row.assetId], row.tag).slice(-16)
|
const trendPoints = buildTrendSeries(currentSnapshot?.historyByAssetId[row.assetId] ?? null, row.tag).slice(-16)
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} className="border-b border-[#252b33]/80 transition hover:bg-white/[0.025]">
|
<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><span className="font-mono text-[11px] font-semibold text-cyan-200">{row.tag}</span></TableCell>
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ describe('WellDashboardPage', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount()
|
root.unmount()
|
||||||
})
|
})
|
||||||
|
vi.useRealTimers()
|
||||||
queryClient.clear()
|
queryClient.clear()
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
@@ -251,7 +252,44 @@ describe('WellDashboardPage', () => {
|
|||||||
expect(applyButton.disabled).toBe(true)
|
expect(applyButton.disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders high-frequency variables with the real configured count and honest data states', async () => {
|
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 () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -264,17 +302,139 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '2 señales reales')
|
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(container.textContent).toContain('Variables de alta frecuencia')
|
||||||
expect(container.textContent).toContain('2 señales reales')
|
expect(highFrequencySection?.textContent).toContain('1 señales reales')
|
||||||
expect(container.textContent).not.toContain('120 señales')
|
expect(highFrequencySection?.textContent).not.toContain('120 señales')
|
||||||
expect(container.textContent).not.toContain('100 señales')
|
expect(highFrequencySection?.textContent).not.toContain('100 señales')
|
||||||
expect(container.textContent).toContain('presion')
|
expect(highFrequencySection?.textContent).toContain('presion')
|
||||||
expect(container.textContent).toContain('120')
|
expect(highFrequencySection?.textContent).toContain('120')
|
||||||
expect(container.textContent).toContain('Temperatura motor')
|
expect(highFrequencySection?.textContent).not.toContain('Temperatura motor')
|
||||||
expect(container.textContent).toContain('Sin dato')
|
expect(highFrequencySection?.textContent).not.toContain('Temperatura alta')
|
||||||
expect(container.textContent).toContain('Sin historial')
|
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 large active high-frequency lists progressively', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const variables = Array.from({ length: 55 }, (_, index) => {
|
||||||
|
const number = index + 1
|
||||||
|
const alias = `alta_${String(number).padStart(2, '0')}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `var-high-${number}`,
|
||||||
|
device_id: 'device-1',
|
||||||
|
address: String(40000 + number),
|
||||||
|
data_type: 'float32',
|
||||||
|
alias,
|
||||||
|
unit: 'PSI',
|
||||||
|
polling_interval_ms: 5000,
|
||||||
|
alarm_enabled: false,
|
||||||
|
byte_order: 'ABCD',
|
||||||
|
metadata: { label: `Alta ${String(number).padStart(2, '0')}` },
|
||||||
|
is_enabled: true,
|
||||||
|
created_at: '2026-06-22T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T00:00:00Z',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const values = Object.fromEntries(variables.map((variable, index) => [variable.alias, index + 100]))
|
||||||
|
const previousValues = Object.fromEntries(variables.map((variable, index) => [variable.alias, index + 90]))
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
||||||
|
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:00Z', telemetry: { values } }],
|
||||||
|
})
|
||||||
|
vi.mocked(fetchAssetTelemetryHistory).mockResolvedValue({ records: [
|
||||||
|
{ asset_id: 'well-1', time: '2026-06-22T09:55:00Z', data: { values: previousValues } },
|
||||||
|
{ asset_id: 'well-1', time: '2026-06-22T10:00:00Z', data: { values } },
|
||||||
|
] })
|
||||||
|
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()
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 10 && !container.textContent?.includes('55 señales reales'); attempt += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
vi.advanceTimersByTime(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
||||||
|
|
||||||
|
expect(highFrequencySection?.textContent).toContain('55 señales reales')
|
||||||
|
expect(highFrequencySection?.textContent).toContain('Alta 24')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Alta 25')
|
||||||
|
expect(highFrequencySection?.textContent).toContain('Cargando 31 señales activas más')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(highFrequencySection?.textContent).toContain('Alta 48')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Alta 49')
|
||||||
|
expect(highFrequencySection?.textContent).toContain('Cargando 7 señales activas más')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(highFrequencySection?.textContent).toContain('Alta 55')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Cargando')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Sin dato')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Sin historial')
|
||||||
|
|
||||||
|
const refreshedValues = Object.fromEntries(variables.map((variable, index) => [variable.alias, index + 200]))
|
||||||
|
await act(async () => {
|
||||||
|
queryClient.setQueryData(['well-dashboard-summary'], {
|
||||||
|
data: [{ asset_id: 'well-1', asset: 'Pozo Norte', last_updated: '2026-06-22T10:00:05Z', telemetry: { values: refreshedValues } }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await flushQueries()
|
||||||
|
|
||||||
|
expect(highFrequencySection?.textContent).toContain('Alta 55')
|
||||||
|
expect(highFrequencySection?.textContent).not.toContain('Cargando')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders low-frequency variables separately from high-frequency variables', async () => {
|
it('renders low-frequency variables separately from high-frequency variables', async () => {
|
||||||
@@ -290,12 +450,12 @@ describe('WellDashboardPage', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, '2 señales reales')
|
await waitForText(container, '1 señales reales')
|
||||||
|
|
||||||
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
const highFrequencySection = container.querySelector('section[aria-label="Variables de alta frecuencia"]')
|
||||||
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
const lowFrequencySection = container.querySelector('section[aria-label="Variables de baja frecuencia"]')
|
||||||
|
|
||||||
expect(highFrequencySection?.textContent).toContain('2 señales reales')
|
expect(highFrequencySection?.textContent).toContain('1 señales reales')
|
||||||
expect(highFrequencySection?.textContent).not.toContain('Nivel tanque')
|
expect(highFrequencySection?.textContent).not.toContain('Nivel tanque')
|
||||||
expect(lowFrequencySection?.textContent).toContain('Nivel tanque')
|
expect(lowFrequencySection?.textContent).toContain('Nivel tanque')
|
||||||
expect(lowFrequencySection?.textContent).toContain('72')
|
expect(lowFrequencySection?.textContent).toContain('72')
|
||||||
@@ -321,9 +481,15 @@ describe('WellDashboardPage', () => {
|
|||||||
await flushQueries()
|
await flushQueries()
|
||||||
await waitForText(container, 'Sin alertas activas')
|
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 dato')
|
||||||
expect(container.textContent).toContain('Sin alertas activas')
|
expect(container.textContent).toContain('Sin alertas activas')
|
||||||
expect(container.textContent).toContain('Sin historial')
|
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(fetchAssetTelemetryHistory).toHaveBeenCalledWith('well-1', 1, 100)
|
||||||
expect(fetchAssetTelemetry).not.toHaveBeenCalled()
|
expect(fetchAssetTelemetry).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type FrequencyClass = 'high' | 'low' | 'unknown'
|
|||||||
|
|
||||||
const GAUGE_RANGE_STORAGE_PREFIX = 'well-dashboard:gauge-range'
|
const GAUGE_RANGE_STORAGE_PREFIX = 'well-dashboard:gauge-range'
|
||||||
const gaugeArcPath = 'M 20 88 A 68 68 0 0 1 156 88'
|
const gaugeArcPath = 'M 20 88 A 68 68 0 0 1 156 88'
|
||||||
|
const HIGH_FREQUENCY_BATCH_SIZE = 24
|
||||||
|
const HIGH_FREQUENCY_BATCH_DELAY_MS = 16
|
||||||
|
|
||||||
function normalizeHistoryRecord(record: TelemetryHistoryRecord): { time: string; asset_id?: string; data?: Record<string, unknown> } {
|
function normalizeHistoryRecord(record: TelemetryHistoryRecord): { time: string; asset_id?: string; data?: Record<string, unknown> } {
|
||||||
const data = record.data || record.values || record.telemetry || record
|
const data = record.data || record.values || record.telemetry || record
|
||||||
@@ -235,6 +237,7 @@ export default function WellDashboardPage() {
|
|||||||
const [storedGaugeRanges, setStoredGaugeRanges] = useState<Record<string, { min: number; max: number }>>({})
|
const [storedGaugeRanges, setStoredGaugeRanges] = useState<Record<string, { min: number; max: number }>>({})
|
||||||
const [draftGaugeRanges, setDraftGaugeRanges] = useState<Record<string, { min: string; max: string }>>({})
|
const [draftGaugeRanges, setDraftGaugeRanges] = useState<Record<string, { min: string; max: string }>>({})
|
||||||
const [selectedVariableId, setSelectedVariableId] = useState<string | null>(null)
|
const [selectedVariableId, setSelectedVariableId] = useState<string | null>(null)
|
||||||
|
const [highFrequencyRenderState, setHighFrequencyRenderState] = useState({ key: '', count: HIGH_FREQUENCY_BATCH_SIZE })
|
||||||
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
||||||
const projects = useAuthStore((s) => s.projects)
|
const projects = useAuthStore((s) => s.projects)
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
@@ -313,7 +316,17 @@ export default function WellDashboardPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [activeAlarms, enabledVariables, history, latestAssetData?.telemetry])
|
}, [activeAlarms, enabledVariables, history, latestAssetData?.telemetry])
|
||||||
const highFrequencySignals = useMemo(() => variableSignals.filter((signal) => signal.frequencyClass !== 'low'), [variableSignals])
|
const highFrequencySignals = useMemo(() => (
|
||||||
|
variableSignals.filter((signal) => signal.frequencyClass !== 'low' && typeof signal.value === 'number' && Number.isFinite(signal.value))
|
||||||
|
), [variableSignals])
|
||||||
|
const highFrequencySignalIdentityKey = useMemo(() => (
|
||||||
|
highFrequencySignals.map((signal) => signal.variable.id || signal.variable.alias).join('|')
|
||||||
|
), [highFrequencySignals])
|
||||||
|
const highFrequencyVisibleCount = highFrequencyRenderState.key === highFrequencySignalIdentityKey
|
||||||
|
? highFrequencyRenderState.count
|
||||||
|
: Math.min(HIGH_FREQUENCY_BATCH_SIZE, highFrequencySignals.length)
|
||||||
|
const visibleHighFrequencySignals = highFrequencySignals.slice(0, highFrequencyVisibleCount)
|
||||||
|
const remainingHighFrequencySignals = Math.max(highFrequencySignals.length - visibleHighFrequencySignals.length, 0)
|
||||||
const lowFrequencySignals = useMemo(() => variableSignals.filter((signal) => signal.frequencyClass === 'low'), [variableSignals])
|
const lowFrequencySignals = useMemo(() => variableSignals.filter((signal) => signal.frequencyClass === 'low'), [variableSignals])
|
||||||
const gaugeSignals = useMemo(() => {
|
const gaugeSignals = useMemo(() => {
|
||||||
return variableSignals
|
return variableSignals
|
||||||
@@ -363,6 +376,33 @@ export default function WellDashboardPage() {
|
|||||||
setStoredGaugeRanges(nextRanges)
|
setStoredGaugeRanges(nextRanges)
|
||||||
}, [enabledVariables, wellId])
|
}, [enabledVariables, wellId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighFrequencyRenderState((current) => (
|
||||||
|
current.key === highFrequencySignalIdentityKey
|
||||||
|
? current
|
||||||
|
: { key: highFrequencySignalIdentityKey, count: Math.min(HIGH_FREQUENCY_BATCH_SIZE, highFrequencySignals.length) }
|
||||||
|
))
|
||||||
|
}, [highFrequencySignalIdentityKey, highFrequencySignals.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (highFrequencyVisibleCount >= highFrequencySignals.length) return undefined
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setHighFrequencyRenderState((current) => {
|
||||||
|
const currentCount = current.key === highFrequencySignalIdentityKey
|
||||||
|
? current.count
|
||||||
|
: Math.min(HIGH_FREQUENCY_BATCH_SIZE, highFrequencySignals.length)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: highFrequencySignalIdentityKey,
|
||||||
|
count: Math.min(currentCount + HIGH_FREQUENCY_BATCH_SIZE, highFrequencySignals.length),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, HIGH_FREQUENCY_BATCH_DELAY_MS)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [highFrequencySignalIdentityKey, highFrequencySignals.length, highFrequencyVisibleCount])
|
||||||
|
|
||||||
const toggleVariable = (varName: string) => {
|
const toggleVariable = (varName: string) => {
|
||||||
setVisibleVars((prev) => ({ ...prev, [varName]: prev[varName] === false }))
|
setVisibleVars((prev) => ({ ...prev, [varName]: prev[varName] === false }))
|
||||||
}
|
}
|
||||||
@@ -634,32 +674,39 @@ export default function WellDashboardPage() {
|
|||||||
description="Este pozo no tiene variables habilitadas para mostrar en tiempo real."
|
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 }) => {
|
<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">
|
||||||
const formattedValue = formatVariableValue(value)
|
{visibleHighFrequencySignals.map(({ variable, label, value, sparklinePath, sparklineValues, status }) => {
|
||||||
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
|
const formattedValue = formatVariableValue(value)
|
||||||
|
const hasSparkline = sparklineValues.length >= 2 && sparklinePath
|
||||||
|
|
||||||
return (
|
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 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">
|
<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 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]}`} />
|
<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>
|
||||||
<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>
|
||||||
</div>
|
{remainingHighFrequencySignals > 0 && (
|
||||||
{hasSparkline ? (
|
<p className="rounded-2xl border border-[#202833] bg-[#0b0f14] px-4 py-3 text-xs font-medium text-[#8b98aa]">
|
||||||
<svg viewBox="0 0 100 32" role="img" aria-label={`Historial de ${label}`} className="mt-2 h-8 w-full text-[#7b8798]">
|
Cargando {remainingHighFrequencySignals} señales activas más…
|
||||||
<path d={sparklinePath} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
</p>
|
||||||
</svg>
|
)}
|
||||||
) : (
|
</>
|
||||||
<p className="mt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-[#64748b]">Sin historial</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,12 @@ function getWellContext(asset: WellOverviewSource, projectName?: string | null):
|
|||||||
|
|
||||||
function getAlarmSeverity(alarmType: string): RecentAlertItem['severity'] {
|
function getAlarmSeverity(alarmType: string): RecentAlertItem['severity'] {
|
||||||
if (alarmType === 'ABOVE_MAX' || alarmType === 'AGENT_OFFLINE') return 'critical'
|
if (alarmType === 'ABOVE_MAX' || alarmType === 'AGENT_OFFLINE') return 'critical'
|
||||||
if (alarmType === 'BELOW_MIN' || alarmType === 'TELEMETRY_SILENCE') return 'warning'
|
if (
|
||||||
|
alarmType === 'BELOW_MIN' ||
|
||||||
|
alarmType === 'TELEMETRY_SILENCE' ||
|
||||||
|
alarmType === 'TELEMETRY_SILENCE_HF' ||
|
||||||
|
alarmType === 'TELEMETRY_SILENCE_LF'
|
||||||
|
) return 'warning'
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +395,12 @@ export function countTelemetryVariables(assets: AssetData[], wellAssets: Connect
|
|||||||
export function buildPanoramaSummary(
|
export function buildPanoramaSummary(
|
||||||
assets: AssetData[],
|
assets: AssetData[],
|
||||||
wellAssets: ConnectedWellSource[],
|
wellAssets: ConnectedWellSource[],
|
||||||
alarms: AlarmEvent[] = []
|
alarms: AlarmEvent[] = [],
|
||||||
|
monitoredVariableCount?: number
|
||||||
): PanoramaSummary {
|
): PanoramaSummary {
|
||||||
const connectedWellCount = countConnectedWells(assets, wellAssets)
|
const connectedWellCount = countConnectedWells(assets, wellAssets)
|
||||||
const signalCount = countTelemetryVariables(assets, wellAssets)
|
const signalCount = countTelemetryVariables(assets, wellAssets)
|
||||||
|
const variablesMetricCount = monitoredVariableCount ?? signalCount
|
||||||
const activeAlarmCount = alarms.filter((alarm) => !alarm.acknowledged).length
|
const activeAlarmCount = alarms.filter((alarm) => !alarm.acknowledged).length
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -401,7 +408,7 @@ export function buildPanoramaSummary(
|
|||||||
signalCount,
|
signalCount,
|
||||||
metrics: [
|
metrics: [
|
||||||
{ label: 'Pozos', value: wellAssets.length, sub: 'en operación', icon: Gauge },
|
{ label: 'Pozos', value: wellAssets.length, sub: 'en operación', icon: Gauge },
|
||||||
{ label: 'Variables', value: signalCount, sub: 'monitoreadas', icon: Database },
|
{ label: 'Variables', value: variablesMetricCount, sub: 'monitoreadas', icon: Database },
|
||||||
{ label: 'Alertas', value: activeAlarmCount, sub: 'activas', icon: AlertTriangle, accent: 'text-[#ffb020]' },
|
{ 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: '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: 'Baja frec.', value: null, sub: 'sin fuente configurada', icon: Activity, accent: 'text-[#27d796]' },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
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 }) => {
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
delete props.priority;
|
||||||
<img alt={alt} {...props} />
|
|
||||||
),
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<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" },
|
||||||
|
|||||||
@@ -67,14 +67,46 @@ pub struct CreateEdgeAssetRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 2. Telemetría (Alta Frecuencia)
|
// 2. Telemetría
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "VARCHAR")]
|
||||||
|
pub enum TelemetrySource {
|
||||||
|
#[serde(rename = "SCADA")]
|
||||||
|
#[sqlx(rename = "SCADA")]
|
||||||
|
Scada,
|
||||||
|
#[serde(rename = "PWA")]
|
||||||
|
#[sqlx(rename = "PWA")]
|
||||||
|
Pwa,
|
||||||
|
#[serde(rename = "MANUAL")]
|
||||||
|
#[sqlx(rename = "MANUAL")]
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum TelemetryFrequencyClass {
|
||||||
|
#[serde(rename = "HF")]
|
||||||
|
Hf,
|
||||||
|
#[serde(rename = "LF")]
|
||||||
|
Lf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single source-of-truth for telemetry frequency classification.
|
||||||
|
pub fn telemetry_frequency_class(source: TelemetrySource) -> TelemetryFrequencyClass {
|
||||||
|
match source {
|
||||||
|
TelemetrySource::Scada => TelemetryFrequencyClass::Hf,
|
||||||
|
TelemetrySource::Pwa | TelemetrySource::Manual => TelemetryFrequencyClass::Lf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct TelemetryRecord {
|
pub struct TelemetryRecord {
|
||||||
pub time: DateTime<Utc>,
|
pub time: DateTime<Utc>,
|
||||||
pub asset_id: Uuid,
|
pub asset_id: Uuid,
|
||||||
pub data: serde_json::Value, // JSONB Payload
|
pub data: serde_json::Value, // JSONB Payload
|
||||||
|
pub source: TelemetrySource,
|
||||||
|
pub client_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -89,6 +121,8 @@ pub struct PostTelemetryRequest {
|
|||||||
pub asset_id: Uuid,
|
pub asset_id: Uuid,
|
||||||
pub time: Option<DateTime<Utc>>,
|
pub time: Option<DateTime<Utc>>,
|
||||||
pub data: serde_json::Value,
|
pub data: serde_json::Value,
|
||||||
|
pub source: Option<TelemetrySource>,
|
||||||
|
pub client_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -96,6 +130,8 @@ pub struct TelemetryResponse {
|
|||||||
pub time: DateTime<Utc>,
|
pub time: DateTime<Utc>,
|
||||||
pub asset_id: Uuid,
|
pub asset_id: Uuid,
|
||||||
pub data: serde_json::Value,
|
pub data: serde_json::Value,
|
||||||
|
pub source: TelemetrySource,
|
||||||
|
pub client_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -377,6 +413,38 @@ mod tests {
|
|||||||
assert_eq!(json["timezone"], "America/Bogota");
|
assert_eq!(json["timezone"], "America/Bogota");
|
||||||
assert_eq!(json["is_active"], true);
|
assert_eq!(json["is_active"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_frequency_class_is_derived_only_from_source() {
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_frequency_class(TelemetrySource::Scada),
|
||||||
|
TelemetryFrequencyClass::Hf
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_frequency_class(TelemetrySource::Pwa),
|
||||||
|
TelemetryFrequencyClass::Lf
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
telemetry_frequency_class(TelemetrySource::Manual),
|
||||||
|
TelemetryFrequencyClass::Lf
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_source_uses_official_uppercase_contract() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(TelemetrySource::Scada).unwrap(),
|
||||||
|
serde_json::json!("SCADA")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(TelemetrySource::Pwa).unwrap(),
|
||||||
|
serde_json::json!("PWA")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(TelemetrySource::Manual).unwrap(),
|
||||||
|
serde_json::json!("MANUAL")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
use chrono::Utc;
|
use chrono::{DateTime, Utc};
|
||||||
use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS};
|
use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS};
|
||||||
|
use serde::Deserialize;
|
||||||
use shared_lib::mqtt_messages::{AgentLogEntry, AlarmEvent, HealthReport};
|
use shared_lib::mqtt_messages::{AgentLogEntry, AlarmEvent, HealthReport};
|
||||||
|
use sqlx::Row;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct EdgeTelemetryPayload {
|
||||||
|
project_id: Option<Uuid>,
|
||||||
|
device_name: String,
|
||||||
|
values: serde_json::Value,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum DeviceResolution {
|
||||||
|
Resolved(Uuid),
|
||||||
|
Unresolved,
|
||||||
|
Ambiguous,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
@@ -33,9 +50,8 @@ 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
|
// Large local project configs are compressed before deploy, but retained
|
||||||
// messages in addition to telemetry. Large local project configs are
|
// messages can still exceed rumqttc's small default packet limit.
|
||||||
// compressed before deploy, but still exceed rumqttc's small default limit.
|
|
||||||
mqttoptions.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
|
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);
|
||||||
@@ -43,9 +59,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10);
|
let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10);
|
||||||
|
|
||||||
// Subscribirse a todos los temas de OmniOil
|
// Subscribe only to categories handled by this service. Telemetry remains
|
||||||
client.subscribe("omnioil/#", QoS::AtLeastOnce).await?;
|
// supported but is constrained by the authoritative topic project contract.
|
||||||
info!("📡 Subscribed to all omnioil/# topics.");
|
for topic in [
|
||||||
|
"omnioil/health/+",
|
||||||
|
"omnioil/logs/+",
|
||||||
|
"omnioil/telemetry/+",
|
||||||
|
"omnioil/alarms/+",
|
||||||
|
] {
|
||||||
|
client.subscribe(topic, QoS::AtLeastOnce).await?;
|
||||||
|
}
|
||||||
|
info!("📡 Subscribed to OmniOil health, logs, telemetry, and alarms topics.");
|
||||||
|
|
||||||
// 3. Event Loop - Processing Messages
|
// 3. Event Loop - Processing Messages
|
||||||
loop {
|
loop {
|
||||||
@@ -177,69 +201,60 @@ async fn process_message(pool: &sqlx::PgPool, topic: &str, payload: &[u8]) -> an
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
"telemetry" => {
|
"telemetry" => {
|
||||||
let payload_json: serde_json::Value = serde_json::from_slice(&decompressed_payload)?;
|
let payload: EdgeTelemetryPayload = serde_json::from_slice(&decompressed_payload)?;
|
||||||
|
|
||||||
// 1. Resolver Asset ID (Prioridad: ID explícito > Búsqueda por Código > Búsqueda por Nombre de Dispositivo)
|
if !payload_project_matches_topic(payload.project_id, project_id) {
|
||||||
let mut asset_id = payload_json
|
warn!(
|
||||||
.get("asset_id")
|
topic_project_id = %project_id,
|
||||||
.and_then(|id| id.as_str())
|
payload_project_id = ?payload.project_id,
|
||||||
.and_then(|id| Uuid::parse_str(id).ok());
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: payload project_id does not match authoritative topic project_id"
|
||||||
if asset_id.is_none() {
|
);
|
||||||
// Opción A: Buscar por código de activo (asset field)
|
return Ok(());
|
||||||
if let Some(code) = payload_json.get("asset").and_then(|p| p.as_str()) {
|
|
||||||
let res: Option<(Uuid,)> = sqlx::query_as(
|
|
||||||
"SELECT id FROM edge_assets WHERE code = $1 OR name = $1 LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(code)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
if let Some((id,)) = res {
|
|
||||||
asset_id = Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if asset_id.is_none() {
|
let asset_id = match resolve_device(pool, project_id, &payload.device_name).await? {
|
||||||
// Opción B: Buscar por nombre de dispositivo (dispositivos están asociados a un activo)
|
DeviceResolution::Resolved(asset_id) => asset_id,
|
||||||
if let Some(device_name) = payload_json.get("device_name").and_then(|p| p.as_str())
|
DeviceResolution::Unresolved => {
|
||||||
{
|
warn!(
|
||||||
let res: Option<(Uuid,)> =
|
project_id = %project_id,
|
||||||
sqlx::query_as("SELECT asset_id FROM edge_devices WHERE name = $1 LIMIT 1")
|
device_name = %payload.device_name,
|
||||||
.bind(device_name)
|
"MQTT telemetry dropped: device could not be resolved for project; no telemetry_raw row inserted"
|
||||||
.fetch_optional(pool)
|
);
|
||||||
.await?;
|
return Ok(());
|
||||||
if let Some((id,)) = res {
|
|
||||||
asset_id = Some(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
DeviceResolution::Ambiguous => {
|
||||||
|
warn!(
|
||||||
|
project_id = %project_id,
|
||||||
|
device_name = %payload.device_name,
|
||||||
|
"MQTT telemetry dropped: device resolution is ambiguous for project; no telemetry_raw row inserted"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 3. Extraer timestamp
|
sqlx::query(insert_scada_telemetry_sql())
|
||||||
let ts = payload_json
|
.bind(payload.timestamp)
|
||||||
.get("timestamp")
|
.bind(project_id)
|
||||||
.and_then(|t| t.as_str())
|
.bind(asset_id)
|
||||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
.bind(&payload.values)
|
||||||
.map(|t| t.with_timezone(&chrono::Utc))
|
.bind(Option::<String>::None)
|
||||||
.unwrap_or_else(|| chrono::Utc::now());
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO telemetry_raw (project_id, asset_id, data, time) VALUES ($1, $2, $3, $4)"
|
|
||||||
)
|
|
||||||
.bind(project_id)
|
|
||||||
.bind(asset_id)
|
|
||||||
.bind(&payload_json)
|
|
||||||
.bind(ts)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
debug!(
|
debug!(
|
||||||
"📡 Telemetry ingested (Asset: {:?}, TS: {}): {}",
|
"📡 SCADA telemetry ingested (Asset: {}, TS: {}): {}",
|
||||||
asset_id, ts, project_id
|
asset_id, payload.timestamp, project_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Evaluar umbrales para cada variable del payload
|
// Evaluar umbrales para cada variable del payload
|
||||||
if let Err(e) =
|
if let Err(e) = evaluate_thresholds(
|
||||||
evaluate_thresholds(pool, project_id, asset_id, &payload_json, &ts).await
|
pool,
|
||||||
|
project_id,
|
||||||
|
Some(asset_id),
|
||||||
|
&payload.values,
|
||||||
|
&payload.timestamp,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Threshold evaluation error: {}", e);
|
tracing::warn!("Threshold evaluation error: {}", e);
|
||||||
}
|
}
|
||||||
@@ -276,6 +291,45 @@ async fn process_message(pool: &sqlx::PgPool, topic: &str, payload: &[u8]) -> an
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn payload_project_matches_topic(payload_project_id: Option<Uuid>, topic_project_id: Uuid) -> bool {
|
||||||
|
match payload_project_id {
|
||||||
|
Some(payload_project_id) => payload_project_id == topic_project_id,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_device(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
device_name: &str,
|
||||||
|
) -> anyhow::Result<DeviceResolution> {
|
||||||
|
let rows = sqlx::query(resolve_device_sql())
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(device_name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(match rows.len() {
|
||||||
|
0 => DeviceResolution::Unresolved,
|
||||||
|
1 => DeviceResolution::Resolved(rows[0].get("asset_id")),
|
||||||
|
_ => DeviceResolution::Ambiguous,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_device_sql() -> &'static str {
|
||||||
|
r#"SELECT d.asset_id
|
||||||
|
FROM edge_devices d
|
||||||
|
JOIN edge_assets a ON d.asset_id = a.id
|
||||||
|
WHERE a.project_id = $1
|
||||||
|
AND d.name = $2"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_scada_telemetry_sql() -> &'static str {
|
||||||
|
r#"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)
|
||||||
|
VALUES ($1, $2, $3, $4, 'SCADA', $5)
|
||||||
|
ON CONFLICT (time, asset_id) DO NOTHING"#
|
||||||
|
}
|
||||||
|
|
||||||
/// Evalúa el payload de telemetría contra los umbrales definidos en edge_variables.
|
/// Evalúa el payload de telemetría contra los umbrales definidos en edge_variables.
|
||||||
/// Si una variable supera su umbral, inserta en telemetry_alarms (con cooldown de 15 min).
|
/// Si una variable supera su umbral, inserta en telemetry_alarms (con cooldown de 15 min).
|
||||||
async fn evaluate_thresholds(
|
async fn evaluate_thresholds(
|
||||||
@@ -317,7 +371,11 @@ async fn evaluate_thresholds(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for var in &vars {
|
for var in &vars {
|
||||||
let raw_val = match obj.get("values").and_then(|v| v.as_object()).and_then(|o| o.get(&var.alias)) {
|
let raw_val = match obj
|
||||||
|
.get("values")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|o| o.get(&var.alias))
|
||||||
|
{
|
||||||
Some(v) => Some(v),
|
Some(v) => Some(v),
|
||||||
None => obj.get(&var.alias),
|
None => obj.get(&var.alias),
|
||||||
};
|
};
|
||||||
@@ -407,3 +465,65 @@ async fn evaluate_thresholds(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_project_id_must_match_authoritative_topic_when_present() {
|
||||||
|
let topic_project_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
assert!(payload_project_matches_topic(None, topic_project_id));
|
||||||
|
assert!(payload_project_matches_topic(
|
||||||
|
Some(topic_project_id),
|
||||||
|
topic_project_id
|
||||||
|
));
|
||||||
|
assert!(!payload_project_matches_topic(
|
||||||
|
Some(Uuid::new_v4()),
|
||||||
|
topic_project_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_uses_strict_project_device_join() {
|
||||||
|
let sql = resolve_device_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains("FROM edge_devices d"));
|
||||||
|
assert!(sql.contains("JOIN edge_assets a ON d.asset_id = a.id"));
|
||||||
|
assert!(sql.contains("a.project_id = $1"));
|
||||||
|
assert!(sql.contains("d.name = $2"));
|
||||||
|
assert!(!sql.contains("d.project_id"));
|
||||||
|
assert!(!sql.contains("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scada_insert_requires_asset_source_client_and_idempotency() {
|
||||||
|
let sql = insert_scada_telemetry_sql();
|
||||||
|
|
||||||
|
assert!(sql.contains(
|
||||||
|
"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)"
|
||||||
|
));
|
||||||
|
assert!(sql.contains("VALUES ($1, $2, $3, $4, 'SCADA', $5)"));
|
||||||
|
assert!(sql.contains("ON CONFLICT (time, asset_id) DO NOTHING"));
|
||||||
|
assert!(!sql.contains("NULL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edge_telemetry_payload_requires_device_values_and_timestamp() {
|
||||||
|
let project_id = Uuid::new_v4();
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"device_name": "pump-1",
|
||||||
|
"values": { "pressure": 10 },
|
||||||
|
"timestamp": "2026-06-27T00:00:00Z"
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed: EdgeTelemetryPayload = serde_json::from_value(payload).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.project_id, Some(project_id));
|
||||||
|
assert_eq!(parsed.device_name, "pump-1");
|
||||||
|
assert_eq!(parsed.values["pressure"], 10);
|
||||||
|
assert_eq!(parsed.timestamp.to_rfc3339(), "2026-06-27T00:00:00+00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user