# frozen_string_literal: true # Dron::Vigilante — Módulo atómico para monitoreo de flota # # Responsabilidad única: Escanear flota y detectar anomalías # - Lee dron_logs para obtener estado de la flota # - Detecta zombies (heartbeat > 5min) # - Registra avances en dron_avances # - Consolida resumen en events (visible en Bitácora Web) # - Alerta sobre anomalías # # Uso: # require_relative 'dron/vigilante' # Dron::Vigilante.escanear_flota require_relative '../../db/core/dron_db' require_relative '../../core/constants' require_relative '../../core/logger' module Dron class Vigilante # Tiempo máximo sin heartbeat antes de considerar zombie (5 minutos) ZOMBIE_TIMEOUT = 300 # Umbral para alerta de anomalías (>5 fallos en 1 hora) ANOMALY_THRESHOLD = 5 class << self # Escanear toda la flota y reportar estado # # @return [Hash] Resumen de la flota def escanear_flota @logger = ADN::Logger.new @logger.info("👁️ Dron Vigilante: Escaneando flota...") resumen = ADN::DB::DronDB.resumen_flota # Mostrar resumen puts "\n🛸 Flota de Drones" puts "═" * 60 puts " Total: #{resumen[:total]}" puts " 🟢 Running: #{resumen[:running] || 0}" puts " ⏳ Pending: #{resumen[:pending] || 0}" puts " ✅ Completed: #{resumen[:completed] || 0}" puts " ❌ Failed: #{resumen[:failed] || 0}" puts " 🧟 Zombies: #{resumen[:zombie] || 0}" puts "═" * 60 { exitoso: true, resumen: resumen, timestamp: Time.now } rescue StandardError => e @logger.error("👁️ Error escaneando flota: #{e.message}") { exitoso: false, error: e.message, timestamp: Time.now } end # Detectar drones zombies (heartbeat > 5min) # # @return [Array] Lista de drones zombies detectados def detectar_zombies @logger = ADN::Logger.new zombies = ADN::DB::DronDB.detectar_zombies if zombies.empty? @logger.info("👁️ No hay drones zombies") return [] end @logger.warn("👁️ Detectados #{zombies.count} drones zombies:") zombies.each do |zombie| minutos = zombie['minutos_sin_heartbeat'].to_f.round(1) @logger.warn(" 🧟 #{zombie['dron_id']} - #{minutos}min sin heartbeat") @logger.warn(" Tipo: #{zombie['tipo']}") @logger.warn(" Cmd: #{zombie['cmd']&.slice(0, 50)}...") # Marcar como zombie en DB ADN::DB::DronDB.marcar_zombie(zombie['dron_id']) # Registrar avance del incidente registrar_avance_incidente(zombie['dron_id'], "Dron detectado como zombie (#{minutos}min sin heartbeat)") end zombies end # Verificar salud de la flota (health check activo) # # @return [Hash] Diagnóstico de salud def salud @logger = ADN::Logger.new @logger.info("👁️ Dron Vigilante: Health check...") diagnosticos = { zombies: [], fallos_recientes: [], anomalias: [], saludable: true } # 1. Detectar zombies zombies = detectar_zombies diagnosticos[:zombies] = zombies.map { |z| z['dron_id'] } # 2. Verificar fallos recientes (últimos 60 min) fallos_recientes = verificar_fallos_recientes diagnosticos[:fallos_recientes] = fallos_recientes # 3. Detectar anomalías (>5 fallos en 1h) if fallos_recientes.count >= ANOMALY_THRESHOLD diagnosticos[:anomalias] << "Demasiados fallos en la última hora: #{fallos_recientes.count}" diagnosticos[:saludable] = false @logger.error("🚨 ANOMALÍA DETECTADA: #{diagnosticos[:anomalias].first}") end # 4. Verificar drones running muy antiguos (>1 hora) running_antiguos = verificar_running_antiguos unless running_antiguos.empty? diagnosticos[:anomalias] << "Drones running demasiado antiguos: #{running_antiguos.count}" diagnosticos[:saludable] = false end # Resumen puts "\n🏥 Salud de la Flota" puts "═" * 60 puts " Estado: #{diagnosticos[:saludable] ? '✅ Saludable' : '🚨 Problemas detectados'}" puts " Zombies: #{diagnosticos[:zombies].count}" puts " Fallos recientes: #{diagnosticos[:fallos_recientes].count}" puts " Anomalías: #{diagnosticos[:anomalias].count}" unless diagnosticos[:anomalias].empty? puts "\n Detalle de anomalías:" diagnosticos[:anomalias].each { |a| puts " ⚠️ #{a}" } end puts "═" * 60 diagnosticos end # Consolidar estado de un flujo y actualizar evento resumen # # @param flujo_id [String] ID del flujo a consolidar # @return [String, nil] Estado consolidado def consolidar_flujo(flujo_id) @logger = ADN::Logger.new estado = ADN::DB::DronDB.consolidar_estado_flujo(flujo_id) return nil unless estado drones = ADN::DB::DronDB.obtener_por_flujo(flujo_id) count = drones.count # Actualizar evento resumen en bitácora (visible en web) actualizar_evento_resumen(flujo_id, estado, count) @logger.info("👁️ Flujo #{flujo_id} consolidado: #{estado} (#{count} drones)") estado end # Obtener dashboard de la flota (versión compacta) # # @return [String] Dashboard formateado def dashboard resumen = ADN::DB::DronDB.resumen_flota activos = ADN::DB::DronDB.obtener_activos output = [] output << "\n🛸 Dashboard de Drones" output << "═" * 70 output << " Total: #{resumen[:total]} | 🟢 #{resumen[:running] || 0} | ⏳ #{resumen[:pending] || 0} | ✅ #{resumen[:completed] || 0} | ❌ #{resumen[:failed] || 0} | 🧟 #{resumen[:zombie] || 0}" output << "═" * 70 unless activos.empty? output << "\n Activos:" output << " #{"ID".ljust(25)} | #{"Tipo".ljust(15)} | #{"Inicio".ljust(10)} | Cmd" output << " " + "─" * 70 activos.each do |dron| id_corto = dron['dron_id'].slice(0, 24) tipo = dron['tipo'].to_s.slice(0, 14) inicio = dron['started_at'].strftime('%H:%M:%S') cmd = dron['cmd']&.slice(0, 30) || '' output << " #{id_corto.ljust(25)} | #{tipo.ljust(15)} | #{inicio.ljust(10)} | #{cmd}" end end output.join("\n") end private # Verificar fallos en la última hora # # @return [Array] Lista de drones fallidos recientemente def verificar_fallos_recientes sql = <<-SQL SELECT * FROM bitacoras.dron_logs WHERE estado = 'failed' AND completed_at > NOW() - INTERVAL '60 minutes' ORDER BY completed_at DESC SQL require_relative '../../db/core/bitacora_db' BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a } end # Verificar drones running demasiado antiguos (>1 hora) # # @return [Array] Lista de drones antiguos def verificar_running_antiguos sql = <<-SQL SELECT * FROM bitacoras.dron_logs WHERE estado = 'running' AND started_at < NOW() - INTERVAL '1 hour' ORDER BY started_at ASC SQL require_relative '../../db/core/bitacora_db' BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a } end # Registrar avance de incidente en dron_avances # # @param dron_id [String] ID del dron # @param descripcion [String] Descripción del incidente def registrar_avance_incidente(dron_id, descripcion) require_relative '../../db/core/bitacora_db' sql = <<-SQL INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado) VALUES ($1, -1, $2, 'failed') RETURNING * SQL BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id, descripcion]).to_a } end # Actualizar evento resumen en bitácora (visible en web) # # @param flujo_id [String] ID del flujo # @param estado [String] Estado consolidado # @param count [Integer] Cantidad de drones def actualizar_evento_resumen(flujo_id, estado, count) require_relative '../../db/core/bitacora_db' estado_icono = case estado when 'completed' then '✅' when 'failed' then '❌' else '⏳' end descripcion = "🛸 Flujo #{flujo_id}: #{count} drones - #{estado_icono}" # Buscar evento existente por flujo_id en metadata sql = <<-SQL SELECT id FROM bitacoras.events WHERE metadata->>'flujo_id' = $1 SQL resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]).to_a } if resultado.empty? # Crear nuevo evento resumen BitacorasDB::BitacoraDB.crear_evento( nodo_id: 1, descripcion: descripcion, estado: estado_icono, metadata: { flujo_id: flujo_id, drones_count: count }.to_json ) else # Actualizar evento existente evento_id = resultado.first['id'] BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion, estado: estado_icono) end rescue StandardError => e @logger.warn("⚠️ No se pudo actualizar evento resumen: #{e.message}") end end end end