66 Commits

Author SHA1 Message Date
henryor
34754810ba feat(telemetry): split silence alarms by origin 2026-06-27 08:46:14 -05:00
henryor
c3668d047f feat(telemetry): persist scada mqtt by resolved device 2026-06-27 08:46:03 -05:00
henryor
6315307e4a fix(telemetry): persist lf source with project access 2026-06-27 08:45:50 -05:00
henryor
1f940e8164 feat(telemetry): add source contract schema 2026-06-27 08:45:37 -05:00
Wilman Yesid Farfan Diaz
959c89b5d3 fix(edge): resolve 0xc0000409 crash and configure auto-restart 2026-06-26 21:58:48 -05:00
henryor
df31ea1041 perf(admin): progressively render high-frequency variables 2026-06-26 21:24:45 -05:00
Wilman Yesid Farfan Diaz
e661caa514 feat: add multi-stage Dockerfiles for centralized service building and deployment 2026-06-26 21:16:16 -05:00
Wilman Yesid Farfan Diaz
d9daa6e6e4 feat: implement multi-stage Docker builds using cargo-chef for all backend services and optimize frontend build caching 2026-06-26 21:03:55 -05:00
henryor
5b68e2c80e fix(admin): keep variables explorer data stable 2026-06-26 20:42:48 -05:00
henryor
5b58da0f18 fix(admin): hide inactive high-frequency variables 2026-06-26 19:06:53 -05:00
henryor
70d1922906 fix(admin): sum panorama variables by well 2026-06-26 18:41:14 -05:00
henryor
d97fc76f16 docs(landing): add Spanish OmniOil Personal docs 2026-06-26 15:51:30 -05:00
henryor
eaaf368053 test(landing): enforce docs content policy 2026-06-26 15:01:12 -05:00
henryor
40cd3ece33 test(landing): cover OmniOil Personal docs 2026-06-26 14:46:17 -05:00
henryor
333c3b3b21 docs(landing): link OmniOil Personal documentation 2026-06-26 14:25:49 -05:00
henryor
5cd46f75a0 docs(landing): add OmniOil Personal docs routes 2026-06-26 09:14:36 -05:00
henryor
f250ad11de fix(admin): scope dashboard by selected well 2026-06-26 07:43:36 -05:00
c8a8089243 Merge pull request 'feat(admin): agregar panorama operacional del dashboard' (#17) from feat/admin-panorama-operations-dashboard into main
Reviewed-on: #17
2026-06-26 00:30:51 +00:00
henryor
565b0c0a61 feat(admin): add panorama operations dashboard 2026-06-25 19:27:09 -05:00
9b806d3fec Merge pull request 'feat/admin-well-dashboard-history' (#15) from feat/admin-well-dashboard-history into main
## Resumen
- Agrega la vista histórica de dashboard por pozo.
- Oculta el selector cuando no hay pozos reales configurados.
- Distingue el estado “Seleccioná un proyecto” del estado “Sin pozos configurados”.
- Preserva la ruta `/realtime` y suma `/wells/:wellId`.

## Verificación
- [x] `pnpm test -- DashboardPage WellDashboardPage`
- [x] `pnpm lint`
- [x] `git diff --check origin/main...HEAD`
2026-06-26 00:21:47 +00:00
henryor
64c42a02e4 test(admin): remove stale dashboard alarm assertion 2026-06-25 19:17:34 -05:00
henryor
338967b949 fix(admin): distinguish unresolved dashboard project 2026-06-25 19:17:34 -05:00
henryor
b331b05d55 feat(admin): add well dashboard history view 2026-06-25 19:17:34 -05:00
henryor
6dd7754206 fix(admin): hide empty well selector 2026-06-25 19:14:50 -05:00
30a16e5b6c Merge pull request 'style(admin): restyle ANH reports console' (#16) from feat/admin-anh-reports-polish into main
Reviewed-on: #16
2026-06-26 00:14:06 +00:00
e229f0ae50 Merge pull request 'feat/admin-project-config-deploy-toggle' (#14) from feat/admin-project-config-deploy-toggle into main
Reviewed-on: #14
2026-06-26 00:12:28 +00:00
47d31837e9 Merge pull request 'feat(simulator): add scalable Modbus load simulator' (#13) from feat/simulator-scalable-modbus-load into main
Reviewed-on: #13
2026-06-26 00:11:52 +00:00
04ef63537c Merge pull request 'feat/admin-realtime-telemetry' (#12) from feat/admin-realtime-telemetry into main
Reviewed-on: #12
2026-06-26 00:11:06 +00:00
henryor
ce9ad1f8f0 style(admin): restyle ANH reports console 2026-06-25 19:04:08 -05:00
henryor
d9c01647ef feat(admin): wire realtime telemetry route 2026-06-25 18:56:56 -05:00
henryor
e615b06f7b feat(admin): add realtime telemetry views 2026-06-25 18:54:40 -05:00
henryor
9559eb4e7d feat(admin): add realtime telemetry view models 2026-06-25 18:54:39 -05:00
henryor
311a5422a9 feat(admin): toggle well collector deployment 2026-06-25 18:54:38 -05:00
henryor
132be581f5 feat(admin): add wells sidebar navigation 2026-06-25 18:54:38 -05:00
henryor
c43642218e feat(admin): add full project config client 2026-06-25 18:54:37 -05:00
henryor
91181431aa feat(simulator): add scalable Modbus load simulator 2026-06-25 18:54:23 -05:00
ac411b1893 Merge pull request 'fix(mqtt): soportar payloads grandes de deploy' (#11) from fix/mqtt-large-deploy-payloads into main
Reviewed-on: #11
2026-06-25 23:18:13 +00:00
henryor
d70efd6b5e fix(mqtt): support larger deploy payloads 2026-06-25 18:08:56 -05:00
49431911fc Merge pull request 'fix(edge): recuperar builds de instalador detenidos' (#10) from fix/edge-installer-recovery into main
Reviewed-on: #10
2026-06-25 23:08:01 +00:00
henryor
f28cb4dad7 fix(edge): recover stalled installer builds 2026-06-25 18:00:22 -05:00
baa906001e Merge pull request 'feat(admin): rediseñar workspace de detalle de proyecto' (#9) from feat/admin-project-detail-workspace into main
Reviewed-on: #9
2026-06-25 22:58:58 +00:00
henryor
f9151dd9ba test(admin): normalize router navigation paths 2026-06-25 17:48:15 -05:00
henryor
f009c43bd2 fix(admin): refresh sidebar after project creation 2026-06-25 17:48:15 -05:00
henryor
113be509ee fix(admin): refresh sidebar after project changes 2026-06-25 17:48:14 -05:00
henryor
439b80dca8 feat(admin): restyle project detail workspace 2026-06-25 17:48:14 -05:00
51e27e49f4 Merge pull request 'feat(admin): renovar vista de proyectos y shell visual' (#8) from feat/admin-projects-overview-visuals into main
Reviewed-on: #8
2026-06-25 22:46:53 +00:00
henryor
948b372f40 style(admin): polish projects overview cards 2026-06-25 17:35:18 -05:00
henryor
3c6cc42216 feat(admin): compact projects overview cards 2026-06-25 17:35:18 -05:00
henryor
675b3033a4 feat(admin): align shell visual system 2026-06-25 17:35:18 -05:00
henryor
b5357a3d0c feat(admin): restyle projects list 2026-06-25 17:35:18 -05:00
761a4ab5a4 Merge pull request 'feat(admin): rediseñar panel SCADA de proyecto' (#7) from feat/admin-project-dashboard-shell into main
Reviewed-on: #7
2026-06-25 22:27:15 +00:00
henryor
89907fbb58 test(admin): use typed project dashboard fixtures 2026-06-25 17:19:45 -05:00
henryor
3dfe4c5659 feat(admin): restyle project agent logs 2026-06-25 17:19:45 -05:00
henryor
741034ac39 feat(admin): restyle project action console 2026-06-25 17:19:44 -05:00
henryor
8cfd7a085b feat(admin): restyle project agent health 2026-06-25 17:19:44 -05:00
henryor
88dc61512f feat(admin): restyle project well cards 2026-06-25 17:19:44 -05:00
henryor
18d8ffa375 feat(admin): add project scada dashboard shell 2026-06-25 17:19:44 -05:00
henryor
c1254566c1 refactor(admin): extract project dashboard layout 2026-06-25 17:19:44 -05:00
d5c768d9c3 Merge pull request 'feat(admin): add SCADA dashboard foundation' (#6) from feat/admin-dashboard-scada-foundation into main
Reviewed-on: #6
2026-06-25 22:16:06 +00:00
b7f1873504 Merge pull request 'chore(repo): ignore local generated artifacts' (#5) from chore/repo-ignore-local-generated-artifacts into main
Reviewed-on: #5
2026-06-25 20:42:55 +00:00
henryor
fac9ab3fe0 feat(admin): add dashboard scada empty states 2026-06-25 15:30:48 -05:00
henryor
3417d8afaf feat(admin): restyle telemetry chart panels 2026-06-25 15:30:48 -05:00
henryor
e6f5412ca2 feat(admin): restyle dashboard kpi cards 2026-06-25 15:30:48 -05:00
henryor
29e89a0a41 feat(admin): add scada dashboard header 2026-06-25 15:30:48 -05:00
henryor
3caf9ba42b refactor(admin): extract dashboard foundation components 2026-06-25 15:30:48 -05:00
henryor
acbf3cc56a chore(repo): ignore local generated artifacts 2026-06-25 15:29:52 -05:00
54 changed files with 3348 additions and 363 deletions

View File

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

View File

@@ -52,10 +52,15 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin backend-api
# 3. BUILDER # 3. BUILDER
# ============================================================================= # =============================================================================
FROM base AS builder FROM base AS builder
COPY . . # Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
# Copiar caché del cacher COPY --from=cacher /app /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/json-generator services/json-generator
COPY services/backend-api services/backend-api
COPY .sqlx .sqlx
RUN cargo build --release --bin backend-api RUN cargo build --release --bin backend-api
# ============================================================================= # =============================================================================

View File

@@ -0,0 +1,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);

View File

@@ -11,6 +11,7 @@ struct TelemetryRow {
asset_code: String, asset_code: String,
time: DateTime<Utc>, time: DateTime<Utc>,
data: Value, data: Value,
source: String,
} }
pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse { pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
@@ -22,7 +23,8 @@ pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl Int
t.asset_id, t.asset_id,
a.code as asset_code, a.code as asset_code,
t.time, t.time,
t.data t.data,
t.source
FROM telemetry_raw t FROM telemetry_raw t
JOIN edge_assets a ON t.asset_id = a.id JOIN edge_assets a ON t.asset_id = a.id
JOIN user_project_access upa ON a.project_id = upa.project_id JOIN user_project_access upa ON a.project_id = upa.project_id
@@ -43,6 +45,7 @@ pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl Int
"asset_id": row.asset_id, "asset_id": row.asset_id,
"asset": row.asset_code, "asset": row.asset_code,
"last_updated": row.time, "last_updated": row.time,
"source": row.source,
"telemetry": row.data "telemetry": row.data
}) })
}) })

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -6,6 +6,17 @@ use std::time::Duration;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
const TELEMETRY_SILENCE_HF: &str = "TELEMETRY_SILENCE_HF";
const TELEMETRY_SILENCE_LF: &str = "TELEMETRY_SILENCE_LF";
#[derive(Clone, Copy)]
struct SilenceSpec {
source: &'static str,
frequency_class: &'static str,
alarm_type: &'static str,
window: chrono::Duration,
}
pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) { pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) {
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health..."); tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
@@ -55,73 +66,133 @@ async fn check_telemetry_silence(
pool: &PgPool, pool: &PgPool,
tx: &broadcast::Sender<crate::TelemetryEvent>, tx: &broadcast::Sender<crate::TelemetryEvent>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos for spec in telemetry_silence_specs() {
let rows = sqlx::query( let window = format_window_interval(spec.window);
r#" let rows = sqlx::query(telemetry_silence_select_sql())
.bind(spec.frequency_class)
.bind(&window)
.fetch_all(pool)
.await?;
for row in rows {
let proj_id: Uuid = row.get("id");
let proj_name: String = row.get("name");
let msg = format!(
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
source = spec.source,
frequency_class = spec.frequency_class,
);
let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
.bind(proj_id)
.bind(spec.alarm_type)
.fetch_optional(pool)
.await?;
if already_alerted.is_none() {
tracing::warn!(
source = spec.source,
frequency_class = spec.frequency_class,
"⚠️ Detectado silencio prolongado en project: {}",
proj_name
);
sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
)
.bind(proj_id)
.bind(spec.alarm_type)
.bind(&msg)
.bind(Utc::now())
.execute(pool)
.await?;
let alarm = AlarmNotification {
project_id: proj_id,
asset_id: None,
variable: spec.alarm_type.to_string(),
value: 0.0,
alarm_type: spec.alarm_type.to_string(),
message: msg,
timestamp: Utc::now(),
};
notifier::dispatch(pool, &alarm, tx).await;
}
}
}
Ok(())
}
fn telemetry_silence_specs() -> [SilenceSpec; 2] {
let hf_minutes = std::env::var("OMNIOIL_TELEMETRY_SILENCE_HF_MINUTES")
.ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
let lf_hours = std::env::var("OMNIOIL_TELEMETRY_SILENCE_LF_HOURS")
.ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
telemetry_silence_specs_from_values(hf_minutes, lf_hours)
}
fn telemetry_silence_specs_from_values(
hf_minutes: Option<i64>,
lf_hours: Option<i64>,
) -> [SilenceSpec; 2] {
let hf_minutes = hf_minutes.unwrap_or(10);
let lf_hours = lf_hours.unwrap_or(24);
[
SilenceSpec {
source: "SCADA",
frequency_class: "HF",
alarm_type: TELEMETRY_SILENCE_HF,
window: chrono::Duration::minutes(hf_minutes),
},
SilenceSpec {
source: "PWA|MANUAL",
frequency_class: "LF",
alarm_type: TELEMETRY_SILENCE_LF,
window: chrono::Duration::hours(lf_hours),
},
]
}
fn format_window_interval(window: chrono::Duration) -> String {
format!("{} seconds", window.num_seconds())
}
pub(crate) fn telemetry_silence_select_sql() -> &'static str {
r#"
SELECT p.id, p.name SELECT p.id, p.name
FROM edge_projects p FROM edge_projects p
WHERE p.is_active = true WHERE p.is_active = true
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM telemetry_raw t SELECT 1 FROM telemetry_raw t
WHERE t.project_id = p.id WHERE t.project_id = p.id
AND t.time > NOW() - INTERVAL '10 minutes' AND CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1
AND t.time > NOW() - ($2::text)::interval
) )
AND EXISTS ( AND EXISTS (
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
) )
"#, "#
) }
.fetch_all(pool)
.await?;
for row in rows { pub(crate) fn telemetry_silence_recent_alarm_sql() -> &'static str {
let proj_id: Uuid = row.get("id"); r#"
let proj_name: String = row.get("name");
let msg = format!(
"ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.",
proj_name
);
let already_alerted = sqlx::query(
r#"
SELECT 1 FROM telemetry_alarms SELECT 1 FROM telemetry_alarms
WHERE project_id = $1 WHERE project_id = $1
AND alarm_type = 'TELEMETRY_SILENCE' AND (
alarm_type = $2
OR ($2 = 'TELEMETRY_SILENCE_HF' AND alarm_type = 'TELEMETRY_SILENCE')
)
AND timestamp > NOW() - INTERVAL '1 hour' AND timestamp > NOW() - INTERVAL '1 hour'
LIMIT 1 LIMIT 1
"#, "#
)
.bind(proj_id)
.fetch_optional(pool)
.await?;
if already_alerted.is_none() {
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
)
.bind(proj_id)
.bind(&msg)
.bind(Utc::now())
.execute(pool)
.await?;
let alarm = AlarmNotification {
project_id: proj_id,
asset_id: None,
variable: "TELEMETRY_SILENCE".to_string(),
value: 0.0,
alarm_type: "TELEMETRY_SILENCE".to_string(),
message: msg,
timestamp: Utc::now(),
};
notifier::dispatch(pool, &alarm, tx).await;
}
}
Ok(())
} }
async fn check_unhealthy_agents( async fn check_unhealthy_agents(
@@ -430,6 +501,36 @@ mod tests {
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'")); assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'")); assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
} }
#[test]
fn telemetry_silence_select_uses_source_derived_frequency_classes() {
let sql = telemetry_silence_select_sql();
assert!(sql.contains("FROM telemetry_raw t"));
assert!(sql.contains("CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1"));
assert!(sql.contains("t.time > NOW() - ($2::text)::interval"));
}
#[test]
fn telemetry_silence_recent_alarm_maps_legacy_silence_to_hf() {
let sql = telemetry_silence_recent_alarm_sql();
assert!(sql.contains("alarm_type = $2"));
assert!(sql.contains("$2 = 'TELEMETRY_SILENCE_HF'"));
assert!(sql.contains("alarm_type = 'TELEMETRY_SILENCE'"));
}
#[test]
fn telemetry_silence_defaults_are_hf_ten_minutes_and_lf_twenty_four_hours() {
let specs = telemetry_silence_specs_from_values(None, None);
assert_eq!(specs[0].frequency_class, "HF");
assert_eq!(specs[0].alarm_type, TELEMETRY_SILENCE_HF);
assert_eq!(format_window_interval(specs[0].window), "600 seconds");
assert_eq!(specs[1].frequency_class, "LF");
assert_eq!(specs[1].alarm_type, TELEMETRY_SILENCE_LF);
assert_eq!(format_window_interval(specs[1].window), "86400 seconds");
}
} }
async fn check_calibration_expirations( async fn check_calibration_expirations(

View File

@@ -51,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin build-orchestrator
# 3. BUILDER # 3. BUILDER
# ============================================================================= # =============================================================================
FROM base AS builder FROM base AS builder
COPY . . # Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
# Copiar caché del cacher COPY --from=cacher /app /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/build-orchestrator services/build-orchestrator
RUN cargo build --release --bin build-orchestrator RUN cargo build --release --bin build-orchestrator
# ============================================================================= # =============================================================================

View File

@@ -51,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin data-collector
# 3. BUILDER # 3. BUILDER
# ============================================================================= # =============================================================================
FROM base AS builder FROM base AS builder
COPY . . # Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
# Copiar caché del cacher COPY --from=cacher /app /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/data-collector services/data-collector
RUN cargo build --release --bin data-collector RUN cargo build --release --bin data-collector
# ============================================================================= # =============================================================================

View File

@@ -23,23 +23,25 @@ pub async fn ingest_telemetry_batch(
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
// 2. Resolver Asset ID por Código (Cacheable en el futuro) // 2. Resolver Asset ID por Código (Cacheable en el futuro)
let asset_opt = sqlx::query("SELECT id FROM edge_assets WHERE code = $1") let asset_opt = sqlx::query("SELECT id, project_id FROM edge_assets WHERE code = $1")
.bind(asset_code) .bind(asset_code)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await?; .await?;
if let Some(asset) = asset_opt { if let Some(asset) = asset_opt {
let asset_id: uuid::Uuid = asset.get("id"); let asset_id: uuid::Uuid = asset.get("id");
let project_id: uuid::Uuid = asset.get("project_id");
// 3. Insertar Telemetría Raw // 3. Insertar Telemetría Raw
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO telemetry_raw (time, asset_id, data) INSERT INTO telemetry_raw (time, project_id, asset_id, data, source)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, 'SCADA')
ON CONFLICT (time, asset_id) DO NOTHING ON CONFLICT (time, asset_id) DO NOTHING
"#, "#,
) )
.bind(&time) .bind(&time)
.bind(&project_id)
.bind(&asset_id) .bind(&asset_id)
.bind(&data) .bind(&data)
.execute(&mut *tx) .execute(&mut *tx)

View File

@@ -78,9 +78,14 @@ ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
ENV OPENSSL_NO_ASM=1 ENV OPENSSL_NO_ASM=1
COPY . . # Copiar el esqueleto dummy desde el cacher (contiene todos los Cargo.toml del workspace)
COPY --from=cacher /app /app
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/edge-agent services/edge-agent
# Parchear la versión del agente directamente en Cargo.toml antes de compilar. # Parchear la versión del agente directamente en Cargo.toml antes de compilar.
ARG BUILD_VERSION=0.3.0 ARG BUILD_VERSION=0.3.0
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \ RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \

View File

@@ -71,12 +71,16 @@ fn set_secret_permissions(path: &Path) -> Result<()> {
} }
// ─── DPAPI (Windows only) ──────────────────────────────────────────────────── // ─── DPAPI (Windows only) ────────────────────────────────────────────────────
// Usa la definición correcta de CRYPT_INTEGER_BLOB directamente desde windows_sys
// para garantizar el alineamiento correcto en x86_64. La struct manual anterior
// podía causar corrupción de stack (0xc0000409) por padding incorrecto.
#[cfg(windows)] #[cfg(windows)]
mod dpapi { mod dpapi {
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use windows_sys::Win32::Security::Cryptography::{ use windows_sys::Win32::Security::Cryptography::{
CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData,
CRYPT_INTEGER_BLOB,
}; };
// LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues. // LocalFree is a legacy Win32 function — declare it directly to avoid feature-flag issues.
@@ -84,31 +88,34 @@ mod dpapi {
fn LocalFree(hmem: *mut std::ffi::c_void) -> *mut std::ffi::c_void; fn LocalFree(hmem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
} }
#[repr(C)] pub fn protect(data: &[u8]) -> Result<Vec<u8>> {
struct DataBlob { // Usar catch_unwind para capturar cualquier abort nativo y convertirlo
cb_data: u32, // en un error manejable en lugar de matar el proceso completo.
pb_data: *mut u8, std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
protect_inner(data)
}))
.unwrap_or_else(|_| Err(anyhow!("CryptProtectData causó un panic/abort — los datos de entrada pueden estar corruptos")))
} }
pub fn protect(data: &[u8]) -> Result<Vec<u8>> { fn protect_inner(data: &[u8]) -> Result<Vec<u8>> {
let input = DataBlob { let mut input = CRYPT_INTEGER_BLOB {
cb_data: data.len() as u32, cbData: data.len() as u32,
pb_data: data.as_ptr() as *mut u8, pbData: data.as_ptr() as *mut u8,
}; };
let mut output = DataBlob { let mut output = CRYPT_INTEGER_BLOB {
cb_data: 0, cbData: 0,
pb_data: std::ptr::null_mut(), pbData: std::ptr::null_mut(),
}; };
let ok = unsafe { let ok = unsafe {
CryptProtectData( CryptProtectData(
&input as *const DataBlob as *const _, &mut input as *mut _,
std::ptr::null(),
std::ptr::null(), std::ptr::null(),
std::ptr::null_mut(), std::ptr::null_mut(),
std::ptr::null(), std::ptr::null_mut(),
std::ptr::null_mut(),
CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_LOCAL_MACHINE,
&mut output as *mut DataBlob as *mut _, &mut output as *mut _,
) )
}; };
@@ -119,30 +126,37 @@ mod dpapi {
} }
let result = let result =
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() }; unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) }; unsafe { LocalFree(output.pbData as *mut std::ffi::c_void) };
Ok(result) Ok(result)
} }
pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> { pub fn unprotect(data: &[u8]) -> Result<Vec<u8>> {
let input = DataBlob { std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cb_data: data.len() as u32, unprotect_inner(data)
pb_data: data.as_ptr() as *mut u8, }))
.unwrap_or_else(|_| Err(anyhow!("CryptUnprotectData causó un panic/abort — los datos cifrados pueden estar corruptos")))
}
fn unprotect_inner(data: &[u8]) -> Result<Vec<u8>> {
let mut input = CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
}; };
let mut output = DataBlob { let mut output = CRYPT_INTEGER_BLOB {
cb_data: 0, cbData: 0,
pb_data: std::ptr::null_mut(), pbData: std::ptr::null_mut(),
}; };
let ok = unsafe { let ok = unsafe {
CryptUnprotectData( CryptUnprotectData(
&input as *const DataBlob as *const _, &mut input as *mut _,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(), std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null_mut(), std::ptr::null_mut(),
std::ptr::null(),
0, 0,
&mut output as *mut DataBlob as *mut _, &mut output as *mut _,
) )
}; };
@@ -153,8 +167,8 @@ mod dpapi {
} }
let result = let result =
unsafe { std::slice::from_raw_parts(output.pb_data, output.cb_data as usize).to_vec() }; unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
unsafe { LocalFree(output.pb_data as *mut std::ffi::c_void) }; unsafe { LocalFree(output.pbData as *mut std::ffi::c_void) };
Ok(result) Ok(result)
} }
} }

View File

@@ -61,12 +61,42 @@ impl DataCollectorManager {
for asset_cfg in assets { for asset_cfg in assets {
for device_cfg in asset_cfg.devices { for device_cfg in asset_cfg.devices {
let device = device_cfg.device;
let reporter = log_reporter.clone();
// Verificar protocolo soportado ANTES de crear la tarea.
// Protocolos sin driver implementado no deben generar JoinHandles fantasma.
match device.protocol {
shared_lib::edge_models::devices::DeviceProtocol::Mqtt => {
let msg = format!(
"⚠️ Dispositivo '{}' usa protocolo MQTT genérico, que no está soportado. \
Usa MQTT_SPARKPLUG_B para recolección de datos, o envía datos \
directamente al Data Gateway HTTP (puerto 8081).",
device.name
);
tracing::warn!("{}", msg);
let _ = reporter.warn("COLLECTOR", &msg).await;
continue;
}
shared_lib::edge_models::devices::DeviceProtocol::EthernetIp
| shared_lib::edge_models::devices::DeviceProtocol::Profinet
| shared_lib::edge_models::devices::DeviceProtocol::ModbusRtu => {
let msg = format!(
"⚠️ Dispositivo '{}' usa protocolo {:?}, que aún no tiene driver implementado. \
Protocolos soportados: MODBUS_TCP, S7, OPC_UA, MQTT_SPARKPLUG_B, SQL_DB.",
device.name, device.protocol
);
tracing::warn!("{}", msg);
let _ = reporter.warn("COLLECTOR", &msg).await;
continue;
}
_ => {} // Protocolo soportado, continuar
}
let mqtt = self.mqtt_client.clone(); let mqtt = self.mqtt_client.clone();
let topic = telemetry_topic.clone(); let topic = telemetry_topic.clone();
let storage_clone = self.storage.clone(); let storage_clone = self.storage.clone();
let project_id = self.project_id; let project_id = self.project_id;
let device = device_cfg.device;
let reporter = log_reporter.clone();
// Map variables // Map variables
let mut variables = HashMap::new(); let mut variables = HashMap::new();
@@ -174,13 +204,14 @@ impl DataCollectorManager {
); );
Box::new(SqlDbDriver::new(conn_str)) Box::new(SqlDbDriver::new(conn_str))
} }
_ => { other => {
let msg = format!( // Este branch no debería alcanzarse porque filtramos arriba,
"Unsupported protocol for device {}: {:?}", // pero lo dejamos como safety net.
device.name, device.protocol tracing::error!(
"❌ Protocol {:?} reached driver init for device {} — this is a bug.",
other,
device.name
); );
tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
return; return;
} }
}; };
@@ -275,7 +306,7 @@ impl DataCollectorManager {
device.name, e device.name, e
); );
let _ = reporter.error("MQTT", &msg).await; let _ = reporter.error("MQTT", &msg).await;
let _ = storage_clone.save(&topic, &payload_str); let _ = storage_clone.save(&topic, &payload_str).await;
} else { } else {
let _ = reporter let _ = reporter
.info( .info(

View File

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

View File

@@ -16,17 +16,44 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
loop { loop {
#[cfg(windows)] #[cfg(windows)]
{ {
let server = ServerOptions::new() // Crear instancia del pipe. Usamos first_pipe_instance(false) para permitir
.first_pipe_instance(true) // que el SO limpie la instancia anterior si quedó en estado inconsistente
.create(pipe_name)?; // (ej. el tray icon se desconectó abruptamente). Esto evita que un error
// en la creación del pipe mate silenciosamente toda la tarea IPC.
let server = match ServerOptions::new()
.first_pipe_instance(false)
.create(pipe_name)
{
Ok(s) => s,
Err(e) => {
tracing::warn!(
"⚠️ IPC: No se pudo crear instancia del pipe '{}': {}. Reintentando en 5s...",
pipe_name,
e
);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
};
// Esperar cliente // Esperar cliente con timeout para no bloquear indefinidamente
server.connect().await?; // si ningún tray icon se conecta.
match server.connect().await {
Ok(_) => {}
Err(e) => {
tracing::warn!(
"⚠️ IPC: Error esperando cliente en pipe: {}. Reintentando...",
e
);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
continue;
}
}
let receiver_clone = receiver.resubscribe(); let receiver_clone = receiver.resubscribe();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_client(server, receiver_clone).await { if let Err(e) = handle_client(server, receiver_clone).await {
tracing::error!("IPC client error: {}", e); tracing::warn!("⚠️ IPC client disconnected: {}", e);
} }
}); });
} }
@@ -55,7 +82,10 @@ async fn handle_client(
match msg { match msg {
Ok(m) => { Ok(m) => {
let json = serde_json::to_string(&m)? + "\n"; let json = serde_json::to_string(&m)? + "\n";
write_half.write_all(json.as_bytes()).await?; if let Err(e) = write_half.write_all(json.as_bytes()).await {
tracing::debug!("IPC write error (client likely disconnected): {}", e);
break;
}
} }
_ => break, _ => break,
} }

View File

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

View File

@@ -1,6 +1,11 @@
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría. //! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA. //! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
//! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre. //! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre.
//!
//! ⚠️ IMPORTANTE: Usa `tokio::sync::Mutex` (NO `std::sync::Mutex`) para evitar
//! deadlocks en el runtime async de Tokio. Si se usa un mutex síncrono dentro de
//! un contexto async, el executor se congela al intentar adquirir un lock que otro
//! task del mismo hilo ya tiene.
use aes_gcm::{ use aes_gcm::{
Aes256Gcm, Nonce, Aes256Gcm, Nonce,
@@ -13,7 +18,7 @@ use rand::RngCore;
use rumqttc::{AsyncClient, QoS}; use rumqttc::{AsyncClient, QoS};
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use sha2::Sha256; use sha2::Sha256;
use std::sync::Mutex; use tokio::sync::Mutex;
const SALT: &[u8; 15] = b"omnioil_db_salt"; const SALT: &[u8; 15] = b"omnioil_db_salt";
@@ -80,7 +85,8 @@ impl StoreAndForward {
} }
/// Guarda un payload ENCRIPTADO usando la conexión persistente. /// Guarda un payload ENCRIPTADO usando la conexión persistente.
pub fn save(&self, topic: &str, payload: &str) -> Result<()> { /// Ahora es async para usar tokio::sync::Mutex de forma segura.
pub async fn save(&self, topic: &str, payload: &str) -> Result<()> {
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes); rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
@@ -90,10 +96,7 @@ impl StoreAndForward {
.encrypt(nonce, payload.as_bytes()) .encrypt(nonce, payload.as_bytes())
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let conn = self let conn = self.conn.lock().await;
.conn
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
conn.execute( conn.execute(
"INSERT INTO telemetry_queue (topic, payload, nonce, created_at) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO telemetry_queue (topic, payload, nonce, created_at) VALUES (?1, ?2, ?3, ?4)",
params![topic, ciphertext, nonce_bytes.to_vec(), Utc::now().to_rfc3339()], params![topic, ciphertext, nonce_bytes.to_vec(), Utc::now().to_rfc3339()],
@@ -101,13 +104,15 @@ impl StoreAndForward {
Ok(()) Ok(())
} }
/// Desencripta y envía los datos usando la conexión persistente. /// Desencripta y envía los datos pendientes vía MQTT.
///
/// ⚠️ CRÍTICO: El lock de la conexión SQLite se libera ANTES de hacer
/// operaciones async de MQTT (.await) para evitar deadlocks en Tokio.
/// El patrón es: lock → read → unlock → send MQTT → lock → delete → unlock.
pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> { pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> {
// Fase 1: Leer filas de la cola (lock corto)
let rows = { let rows = {
let conn = self let conn = self.conn.lock().await;
.conn
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50", "SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50",
)?; )?;
@@ -119,8 +124,10 @@ impl StoreAndForward {
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect(); .collect();
res res
// ← conn lock se libera aquí al salir del bloque
}; };
// Fase 2: Enviar a MQTT SIN el lock (operaciones async seguras)
let mut sent_count = 0; let mut sent_count = 0;
let mut ids_to_delete = Vec::new(); let mut ids_to_delete = Vec::new();
@@ -145,12 +152,9 @@ impl StoreAndForward {
} }
} }
// Batch delete all sent messages in a single transaction // Fase 3: Eliminar mensajes enviados (lock corto de nuevo)
if !ids_to_delete.is_empty() { if !ids_to_delete.is_empty() {
let conn = self let conn = self.conn.lock().await;
.conn
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
let placeholders: Vec<String> = ids_to_delete.iter().map(|_| "?".to_string()).collect(); let placeholders: Vec<String> = ids_to_delete.iter().map(|_| "?".to_string()).collect();
let query = format!( let query = format!(
"DELETE FROM telemetry_queue WHERE id IN ({})", "DELETE FROM telemetry_queue WHERE id IN ({})",
@@ -172,12 +176,9 @@ impl StoreAndForward {
/// ///
/// Útil para evitar acumulación ilimitada durante outages prolongados de MQTT. /// Útil para evitar acumulación ilimitada durante outages prolongados de MQTT.
/// La telemetría tan antigua ya no tiene utilidad operativa. /// La telemetría tan antigua ya no tiene utilidad operativa.
pub fn cleanup_old_queue_entries(&self, max_days: i64) -> Result<usize> { pub async fn cleanup_old_queue_entries(&self, max_days: i64) -> Result<usize> {
let cutoff = (Utc::now() - chrono::Duration::days(max_days)).to_rfc3339(); let cutoff = (Utc::now() - chrono::Duration::days(max_days)).to_rfc3339();
let conn = self let conn = self.conn.lock().await;
.conn
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
let deleted = conn.execute( let deleted = conn.execute(
"DELETE FROM telemetry_queue WHERE created_at < ?1", "DELETE FROM telemetry_queue WHERE created_at < ?1",
params![cutoff], params![cutoff],

View File

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

View File

@@ -51,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin events-relay
# 3. BUILDER # 3. BUILDER
# ============================================================================= # =============================================================================
FROM base AS builder FROM base AS builder
COPY . . # Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
# Copiar caché del cacher COPY --from=cacher /app /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/events-relay services/events-relay
RUN cargo build --release --bin events-relay RUN cargo build --release --bin events-relay
# ============================================================================= # =============================================================================

View File

@@ -8,7 +8,14 @@ export interface AlarmEvent {
current_value?: number | null current_value?: number | null
alarm_min?: number | null alarm_min?: number | null
alarm_max?: number | null alarm_max?: number | null
alarm_type: 'ABOVE_MAX' | 'BELOW_MIN' | 'TELEMETRY_SILENCE' | 'AGENT_OFFLINE' | string alarm_type:
| 'ABOVE_MAX'
| 'BELOW_MIN'
| 'TELEMETRY_SILENCE'
| 'TELEMETRY_SILENCE_HF'
| 'TELEMETRY_SILENCE_LF'
| 'AGENT_OFFLINE'
| string
message?: string | null message?: string | null
acknowledged?: boolean | null acknowledged?: boolean | null
acknowledged_at?: string | null acknowledged_at?: string | null

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,10 +51,13 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin json-generator
# 3. BUILDER # 3. BUILDER
# ============================================================================= # =============================================================================
FROM base AS builder FROM base AS builder
COPY . . # Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
# Copiar caché del cacher COPY --from=cacher /app /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/json-generator services/json-generator
RUN cargo build --release --bin json-generator RUN cargo build --release --bin json-generator
# ============================================================================= # =============================================================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,21 @@ import { useEffect } from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { RequestAccessSection } from "./RequestAccessSection"; import { RequestAccessSection } from "./RequestAccessSection";
import { Footer } from "./layout/Footer";
import { Navbar } from "./layout/Navbar"; import { Navbar } from "./layout/Navbar";
import { LanguageProvider } from "@/i18n/LanguageContext"; import { LanguageProvider } from "@/i18n/LanguageContext";
const mockUsePathname = vi.fn(() => "/");
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
default: ({ alt, ...props }: { alt: string; [key: string]: unknown }) => ( default: ({ alt, ...props }: { alt: string; priority?: boolean; [key: string]: unknown }) => {
// 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 />);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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