feat: initialize full microservices architecture including backend-api, edge-agent, infrastructure backup, and supporting modules
Some checks failed
Build, MSI & Deploy / Build Docker images (push) Failing after 1m46s
Build, MSI & Deploy / Deploy to Dokploy (push) Has been skipped
Build, MSI & Deploy / Build Edge Agent Binaries (push) Failing after 1m11s
Build, MSI & Deploy / Build Edge Agent MSI (push) Has been skipped
Build, MSI & Deploy / Send notification (push) Successful in 0s

This commit is contained in:
Wilman Yesid Farfan Diaz
2026-07-12 18:22:02 -05:00
parent 2bbc7972e3
commit eb2dcbe4f8
34 changed files with 473 additions and 138 deletions

View File

@@ -24,10 +24,19 @@ mod app {
const PIPE_NAME: &str = r"\\.\pipe\omnioil-edge-agent";
// ─── Colores branding OmniOil ──────────────────────────────────────────────
const BRAND_GOLD: [u8; 3] = [212, 160, 23]; // #D4A017 — dorado llama
const BRAND_RED: [u8; 3] = [231, 76, 60]; // #E74C3C — rojo desconectado
const BRAND_BG: [u8; 3] = [26, 26, 46]; // #1A1A2E — fondo oscuro
struct EdgeTrayApp {
tray_icon: Option<TrayIcon>,
quit_id: MenuId,
test_id: MenuId,
test_connectivity_id: MenuId,
restart_service_id: MenuId,
open_logs_id: MenuId,
open_install_id: MenuId,
about_id: MenuId,
status_label: MenuItem,
command_tx: tokio::sync::mpsc::Sender<AgentCommand>,
status_rx: tokio::sync::mpsc::Receiver<IpcMessage>,
@@ -36,6 +45,7 @@ mod app {
animation_tick: usize,
last_tick_time: std::time::Instant,
connected_mqtt: bool,
exe_dir: std::path::PathBuf,
}
impl ApplicationHandler for EdgeTrayApp {
@@ -125,7 +135,10 @@ mod app {
}
}
IpcMessage::Notification { title, message } => {
println!("[NOTIFICATION] {}: {}", title, message);
self.status_label
.set_text(format!("{}: {}", title, message));
// Restaurar al estado después de 5 segundos
// (el próximo StatusUpdate lo restaurará automáticamente)
}
_ => {}
}
@@ -148,10 +161,28 @@ mod app {
if event.id == self.quit_id {
self.tray_icon.take();
event_loop.exit();
} else if event.id == self.test_id {
let _ = self.command_tx.try_send(AgentCommand::Notify(
"¡Hola desde el Tray Icon!".to_string(),
));
} else if event.id == self.test_connectivity_id {
let _ = self.command_tx.try_send(AgentCommand::CheckConnectivity);
} else if event.id == self.restart_service_id {
let _ = self.command_tx.try_send(AgentCommand::RestartService);
} else if event.id == self.open_logs_id {
let logs_dir = self.exe_dir.join("logs");
let _ = std::process::Command::new("explorer.exe")
.arg(&logs_dir)
.spawn();
} else if event.id == self.open_install_id {
let _ = std::process::Command::new("explorer.exe")
.arg(&self.exe_dir)
.spawn();
} else if event.id == self.about_id {
let version = env!("CARGO_PKG_VERSION");
let status = if self.connected_mqtt {
"MQTT: Conectado"
} else {
"MQTT: Desconectado"
};
self.status_label
.set_text(format!("OmniOil Edge Agent v{}{}", version, status));
}
}
}
@@ -161,20 +192,41 @@ mod app {
let event_loop = EventLoop::new().unwrap();
event_loop.set_control_flow(ControlFlow::Wait);
// Obtener directorio del ejecutable para acciones locales
let exe_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
// Setup Tray Menu
let tray_menu = Menu::new();
let status_label = MenuItem::new("Estado: Conectando...", false, None);
let test_i = MenuItem::new("Probar Notificación", true, None);
let test_connectivity_i = MenuItem::new("Probar Conexión", true, None);
let restart_service_i = MenuItem::new("Reiniciar Servicio", true, None);
let open_logs_i = MenuItem::new("Abrir Logs", true, None);
let open_install_i = MenuItem::new("Abrir Carpeta Instalación", true, None);
let about_i = MenuItem::new("Acerca de...", true, None);
let quit_i = MenuItem::new("Salir", true, None);
let quit_id = quit_i.id().clone();
let test_id = test_i.id().clone();
let test_connectivity_id = test_connectivity_i.id().clone();
let restart_service_id = restart_service_i.id().clone();
let open_logs_id = open_logs_i.id().clone();
let open_install_id = open_install_i.id().clone();
let about_id = about_i.id().clone();
tray_menu
.append_items(&[
&status_label,
&test_i,
&PredefinedMenuItem::separator(),
&test_connectivity_i,
&restart_service_i,
&PredefinedMenuItem::separator(),
&open_logs_i,
&open_install_i,
&PredefinedMenuItem::separator(),
&about_i,
&quit_i,
])
.unwrap();
@@ -253,7 +305,11 @@ mod app {
let mut app = EdgeTrayApp {
tray_icon: Some(tray_icon),
quit_id,
test_id,
test_connectivity_id,
restart_service_id,
open_logs_id,
open_install_id,
about_id,
status_label,
command_tx,
status_rx,
@@ -261,6 +317,7 @@ mod app {
animation_tick: 0,
last_tick_time: std::time::Instant::now(),
connected_mqtt: false,
exe_dir,
};
event_loop.run_app(&mut app).unwrap();
@@ -278,7 +335,7 @@ mod app {
}
}
// Generar icono de estado elegante de 16x16 en memoria
// Generar icono de estado elegante de 16x16 en memoria (branding OmniOil)
let width = 16;
let height = 16;
let mut rgba = vec![0u8; (width * height * 4) as usize];
@@ -290,23 +347,23 @@ mod app {
let is_dot = x >= 11 && x <= 14 && y >= 11 && y <= 14;
if is_dot {
if connected {
// Punto Verde (Conectado)
rgba[idx] = 46;
rgba[idx + 1] = 204;
rgba[idx + 2] = 113;
// Punto Dorado OmniOil (Conectado)
rgba[idx] = BRAND_GOLD[0];
rgba[idx + 1] = BRAND_GOLD[1];
rgba[idx + 2] = BRAND_GOLD[2];
rgba[idx + 3] = 255;
} else {
// Punto Rojo (Desconectado)
rgba[idx] = 231;
rgba[idx + 1] = 76;
rgba[idx + 2] = 60;
rgba[idx] = BRAND_RED[0];
rgba[idx + 1] = BRAND_RED[1];
rgba[idx + 2] = BRAND_RED[2];
rgba[idx + 3] = 255;
}
} else {
// Fondo gris slate de tema oscuro moderno
rgba[idx] = 44;
rgba[idx + 1] = 62;
rgba[idx + 2] = 80;
// Fondo oscuro consistente con branding
rgba[idx] = BRAND_BG[0];
rgba[idx + 1] = BRAND_BG[1];
rgba[idx + 2] = BRAND_BG[2];
rgba[idx + 3] = 255;
}
}
@@ -336,16 +393,16 @@ mod app {
let dist_sq = dx * dx + dy * dy;
if dist_sq <= 1 {
// Punto naranja brillante de animación
rgba[idx] = 255;
rgba[idx + 1] = 165;
rgba[idx + 2] = 0;
// Punto dorado brillante de animación (branding OmniOil)
rgba[idx] = BRAND_GOLD[0];
rgba[idx + 1] = BRAND_GOLD[1];
rgba[idx + 2] = BRAND_GOLD[2];
rgba[idx + 3] = 255;
} else {
// Fondo azul oscuro moderno
rgba[idx] = 0;
rgba[idx + 1] = 50;
rgba[idx + 2] = 100;
// Fondo oscuro consistente
rgba[idx] = BRAND_BG[0];
rgba[idx + 1] = BRAND_BG[1];
rgba[idx + 2] = BRAND_BG[2];
rgba[idx + 3] = 255;
}
}

View File

@@ -433,10 +433,7 @@ async fn run_device_collector(spec: DeviceTaskSpec) {
let _ = storage.save(&topic, &payload_str).await;
} else {
let _ = reporter
.info(
"MQTT",
&format!("✅ Data sent for {}", device.name),
)
.info("MQTT", &format!("✅ Data sent for {}", device.name))
.await;
}
}
@@ -444,17 +441,13 @@ async fn run_device_collector(spec: DeviceTaskSpec) {
let _ = reporter
.warn(
"COLLECTOR",
&format!(
"⚠️ No values returned for device {}",
device.name
),
&format!("⚠️ No values returned for device {}", device.name),
)
.await;
}
}
Err(e) => {
let msg =
format!("❌ Error reading from device {}: {}", device.name, e);
let msg = format!("❌ Error reading from device {}: {}", device.name, e);
tracing::error!("{}", msg);
let _ = reporter.error("COLLECTOR", &msg).await;
let _ = driver.disconnect().await;

View File

@@ -53,11 +53,7 @@ impl DeadbandFilter {
}
Some(entry) => {
let change_pct = if entry.last_value == 0.0 {
if new_val == 0.0 {
0.0
} else {
100.0
}
if new_val == 0.0 { 0.0 } else { 100.0 }
} else {
((new_val - entry.last_value) / entry.last_value.abs() * 100.0).abs()
};

View File

@@ -27,4 +27,10 @@ pub enum IpcMessage {
pub enum AgentCommand {
Restart,
Notify(String),
/// Detiene y reinicia el servicio Windows OmniOilEdgeAgent via SCM.
RestartService,
/// Envía un mensaje de prueba al servidor vía MQTT (log reporter) para
/// verificar conectividad end-to-end. La latencia es ~RTT MQTT (100-500ms
/// local, 1-3s internet).
CheckConnectivity,
}

View File

@@ -8,7 +8,10 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[cfg(windows)]
use tokio::net::windows::named_pipe::ServerOptions;
pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyhow::Result<()> {
pub async fn start_ipc_server(
receiver: broadcast::Receiver<IpcMessage>,
tray_cmd_tx: tokio::sync::mpsc::Sender<AgentCommand>,
) -> anyhow::Result<()> {
let pipe_name = r"\\.\pipe\omnioil-edge-agent";
tracing::info!("📡 IPC Server (Named Pipes) listening on {}...", pipe_name);
@@ -48,8 +51,9 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
}
let receiver_clone = receiver.resubscribe();
let cmd_tx = tray_cmd_tx.clone();
tokio::spawn(async move {
if let Err(e) = handle_client(server, receiver_clone).await {
if let Err(e) = handle_client(server, receiver_clone, cmd_tx).await {
tracing::warn!("⚠️ IPC client disconnected: {}", e);
}
});
@@ -59,6 +63,7 @@ pub async fn start_ipc_server(receiver: broadcast::Receiver<IpcMessage>) -> anyh
#[cfg(not(windows))]
{
let _ = &receiver;
let _ = &tray_cmd_tx;
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}
}
@@ -75,8 +80,8 @@ fn create_pipe_server(
use std::ptr::addr_of_mut;
use windows_sys::Win32::Foundation::{FALSE, TRUE};
use windows_sys::Win32::Security::{
InitializeSecurityDescriptor, SetSecurityDescriptorDacl, SECURITY_ATTRIBUTES,
SECURITY_DESCRIPTOR,
InitializeSecurityDescriptor, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
SetSecurityDescriptorDacl,
};
// SECURITY_DESCRIPTOR_REVISION siempre vale 1 según el Windows SDK; la
// constante fue eliminada en versiones nuevas de windows-sys.
@@ -123,6 +128,7 @@ fn create_pipe_server(
async fn handle_client(
server: tokio::net::windows::named_pipe::NamedPipeServer,
mut receiver: broadcast::Receiver<IpcMessage>,
tray_cmd_tx: tokio::sync::mpsc::Sender<AgentCommand>,
) -> anyhow::Result<()> {
let (read_half, mut write_half) = tokio::io::split(server);
let mut reader = BufReader::new(read_half);
@@ -147,13 +153,21 @@ async fn handle_client(
Ok(0) | Err(_) => break,
Ok(_) => {
if let Ok(IpcMessage::Command(cmd)) = serde_json::from_str(&line) {
match cmd {
match &cmd {
AgentCommand::Restart => tracing::warn!("🔄 Restart requested!"),
AgentCommand::Notify(msg) => {
let resp = IpcMessage::Notification { title: "Agent".into(), message: msg };
let resp = IpcMessage::Notification { title: "Agent".into(), message: msg.clone() };
let _ = write_half.write_all((serde_json::to_string(&resp)? + "\n").as_bytes()).await;
}
AgentCommand::RestartService => {
tracing::info!("🔄 RestartService requested from tray icon");
}
AgentCommand::CheckConnectivity => {
tracing::info!("📡 CheckConnectivity requested from tray icon");
}
}
// Reenviar comandos que requieren acción del agente principal
let _ = tray_cmd_tx.send(cmd).await;
}
line.clear();
}

View File

@@ -107,19 +107,19 @@ fn main() -> Result<()> {
let opts = rumqttc::MqttOptions::new("crash-reporter", mqtt_host, port);
let (client, mut eventloop) = rumqttc::AsyncClient::new(opts, 4);
// Dar 2s para conectar y enviar
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
async {
let _ = eventloop.poll().await; // ConnAck
let _ = client.publish(
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let _ = eventloop.poll().await; // ConnAck
let _ = client
.publish(
&topic,
rumqttc::QoS::AtMostOnce,
false,
serde_json::to_vec(&log_payload).unwrap_or_default(),
).await;
let _ = eventloop.poll().await; // flush
}
).await;
)
.await;
let _ = eventloop.poll().await; // flush
})
.await;
}
});
}
@@ -300,8 +300,11 @@ pub async fn run_agent(
let (ipc_tx, _) = tokio::sync::broadcast::channel::<ipc_protocol::IpcMessage>(32);
let ipc_rx = ipc_tx.subscribe();
// Canal para comandos desde el tray icon hacia el agente principal
let (tray_cmd_tx, mut tray_cmd_rx) = mpsc::channel::<ipc_protocol::AgentCommand>(16);
tokio::spawn(async move {
let _ = ipc_server::start_ipc_server(ipc_rx).await;
let _ = ipc_server::start_ipc_server(ipc_rx, tray_cmd_tx).await;
});
// ─── Limpieza de logs locales ───────────────────────────────────────────────
@@ -556,6 +559,42 @@ pub async fn run_agent(
}
}
}
Some(tray_cmd) = tray_cmd_rx.recv() => {
match tray_cmd {
ipc_protocol::AgentCommand::RestartService => {
log_reporter.info("TRAY", "Restart service requested from tray icon").await;
#[cfg(windows)]
{
// Detener el servicio — esto terminará el proceso actual.
// El Service Control Manager lo reiniciará automáticamente
// gracias a la política de recuperación configurada.
let _ = std::process::Command::new("sc")
.arg("stop")
.arg("OmniOilEdgeAgent")
.output();
}
#[cfg(not(windows))]
{
tracing::warn!("RestartService not supported on non-Windows");
}
}
ipc_protocol::AgentCommand::CheckConnectivity => {
log_reporter.info("CONNECTIVITY_TEST", "Test message from tray icon — connectivity OK").await;
let _ = ipc_tx.send(ipc_protocol::IpcMessage::Notification {
title: "Conectividad".to_string(),
message: "Conexión verificada — el agente está comunicando con el servidor.".to_string(),
});
}
ipc_protocol::AgentCommand::Notify(msg) => {
log_reporter.info("TRAY", &format!("Test notification: {}", msg)).await;
let _ = ipc_tx.send(ipc_protocol::IpcMessage::Notification {
title: "Agent".to_string(),
message: msg,
});
}
_ => {}
}
}
_ = tokio::time::sleep(std::time::Duration::from_secs(15)) => {
// Despierta periódicamente el bucle para actualizar el heartbeat
// del watchdog y demostrar que el runtime de Tokio sigue activo.

View File

@@ -53,8 +53,8 @@ impl StoreAndForward {
// Salt aleatorio por dispositivo — persistir junto al DB
let salt_path = db_path.with_extension("salt");
let salt: [u8; 16] = if salt_path.exists() {
let existing = std::fs::read(&salt_path)
.context("Failed to read existing salt file")?;
let existing =
std::fs::read(&salt_path).context("Failed to read existing salt file")?;
existing.try_into().unwrap_or_else(|_| {
tracing::warn!("Salt file corrupt, generating new salt");
let new: [u8; 16] = rand::random();
@@ -63,8 +63,7 @@ impl StoreAndForward {
})
} else {
let new: [u8; 16] = rand::random();
std::fs::write(&salt_path, &new)
.context("Failed to write salt file")?;
std::fs::write(&salt_path, &new).context("Failed to write salt file")?;
new
};

View File

@@ -25,11 +25,11 @@
//! credenciales almacenadas en el agente. La URL es temporal (TTL 30 min).
//! - El agente nunca expone ningún puerto HTTP.
use crate::ipc_protocol::IpcMessage;
use anyhow::{Context, Result, bail};
use semver::Version;
use sha2::{Digest, Sha256};
use tracing::{info, warn};
use crate::ipc_protocol::IpcMessage;
/// Aplica una actualización OTA a partir de los datos recibidos vía MQTT.
///
@@ -91,7 +91,8 @@ pub async fn apply_ota_from_command(
.build()
.context("Error al construir cliente HTTP para descarga OTA")?;
let content = match download_asset(&client, download_url, target_version, ipc_tx.as_ref()).await {
let content = match download_asset(&client, download_url, target_version, ipc_tx.as_ref()).await
{
Ok(c) => c,
Err(e) => {
if let Some(ref tx) = ipc_tx {