feat(dron): Migración a AtomicDrones con arquitectura DB-First
Implementación del sistema AtomicDrones basado en "Menos es Más": - Refactorización de dron.rb a dispatcher + módulos atómicos - Nuevos módulos: ejecutor, vigilante, sanador, bitacora - DronDB para acceso a datos en PostgreSQL - Tablas: dron_logs, dron_avances, dron_metricas - Views: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal - Health check con detección de zombies (heartbeat > 5min) - Dashboard compacto de la flota - Saneamiento con reintentos (backoff exponencial) y limpieza Comandos soportados: - dron lanzar: Ejecutar comando como dron - dron flota: Dashboard de la flota - dron salud: Health check activo - dron estado <ID>: Estado detallado - dron sanear: Saneamiento completo - dron limpiar: Limpieza de antiguos - dron reintentar: Re-intentar fallidos Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b5e89144e3
commit
3543e62263
@@ -0,0 +1,197 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Dron::Bitacora — Módulo atómico para registro en bitácora humana
|
||||
#
|
||||
# Responsabilidad única: Gestionar registro de eventos en bitácora
|
||||
# - Crea eventos ⏳ al iniciar dron
|
||||
# - Actualiza eventos ✅ o ❌ al finalizar
|
||||
# - Soporta modo AUTO (crea y cierra automáticamente)
|
||||
#
|
||||
# Uso:
|
||||
# require_relative 'dron/bitacora'
|
||||
# Dron::Bitacora.iniciar(nota: 'Backup SQL')
|
||||
# Dron::Bitacora.finalizar(evento_id, exit_code: 0)
|
||||
|
||||
require_relative '../../db/core/bitacora_db'
|
||||
require_relative '../../core/constants'
|
||||
require_relative '../../core/logger'
|
||||
|
||||
module Dron
|
||||
class Bitacora
|
||||
class << self
|
||||
# Registrar inicio de actividad en bitácora
|
||||
#
|
||||
# @param nota [String] Nota descriptiva de la actividad
|
||||
# @param nodo_id [Integer, nil] ID del nodo (default: 1 = srv-ns8)
|
||||
# @param modo [String] Modo de ejecución (P=presencial, R=remoto, A=automático)
|
||||
# @return [Integer, nil] ID del evento creado o nil si falló
|
||||
def iniciar(nota:, nodo_id: 1, modo: 'A')
|
||||
@logger = ADN::Logger.new
|
||||
|
||||
descripcion = "🛸 dron: #{nota}"
|
||||
hora_inicio = Time.now.strftime('%H:%M')
|
||||
|
||||
@logger.info("📝 Bitácora: Iniciando evento - #{nota}")
|
||||
|
||||
sql = <<-SQL
|
||||
INSERT INTO bitacoras.events (nodo_id, descripcion, estado, modo, inicio)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
SQL
|
||||
|
||||
resultado = BitacorasDB::BitacoraDB.ejecutar(sql, [nodo_id, descripcion, '⏳', modo, hora_inicio])
|
||||
|
||||
evento_id = resultado.first['id']
|
||||
@logger.info("📝 Evento creado: ##{evento_id}")
|
||||
|
||||
evento_id
|
||||
rescue StandardError => e
|
||||
@logger.error("📝 Error creando evento: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# Registrar finalización de actividad en bitácora
|
||||
#
|
||||
# @param evento_id [Integer] ID del evento a actualizar
|
||||
# @param exit_code [Integer] Código de salida (0=éxito, >0=fallo)
|
||||
# @param nota [String, nil] Nota adicional para el resumen
|
||||
# @return [Boolean] true si se actualizó correctamente
|
||||
def finalizar(evento_id, exit_code:, nota: nil)
|
||||
@logger = ADN::Logger.new
|
||||
|
||||
estado_icono = exit_code == 0 ? '✅' : '❌'
|
||||
hora_fin = Time.now.strftime('%H:%M')
|
||||
|
||||
# Obtener evento actual para construir descripción
|
||||
evento = obtener_evento(evento_id)
|
||||
return false unless evento
|
||||
|
||||
# Construir nueva descripción
|
||||
descripcion_original = evento['descripcion']
|
||||
nota_adicional = nota ? " — #{nota}" : ""
|
||||
|
||||
# Extraer nota original si existe
|
||||
nota_original = descripcion_original.gsub('🛸 dron: ', '').split(' — ').first
|
||||
descripcion = "🛸 dron: #{nota_original} — #{estado_icono}#{nota_adicional}"
|
||||
|
||||
@logger.info("📝 Bitácora: Finalizando evento ##{evento_id} - #{estado_icono}")
|
||||
|
||||
sql = <<-SQL
|
||||
UPDATE bitacoras.events
|
||||
SET descripcion = $1,
|
||||
estado = $2,
|
||||
fin = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
SQL
|
||||
|
||||
BitacorasDB::BitacoraDB.ejecutar(sql, [descripcion, estado_icono, hora_fin, evento_id])
|
||||
|
||||
@logger.info("📝 Evento ##{evento_id} actualizado")
|
||||
true
|
||||
rescue StandardError => e
|
||||
@logger.error("📝 Error actualizando evento: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
# Registrar inicio y finalización automática (modo AUTO)
|
||||
# Útil para comandos rápidos que no requieren seguimiento
|
||||
#
|
||||
# @param nota [String] Nota descriptiva
|
||||
# @param bloque [Proc] Bloque de código a ejecutar
|
||||
# @return [Hash] Resultado de la ejecución
|
||||
def auto(nota:, &bloque)
|
||||
@logger = ADN::Logger.new
|
||||
|
||||
# Registrar inicio
|
||||
evento_id = iniciar(nota: nota)
|
||||
|
||||
# Ejecutar bloque
|
||||
inicio = Time.now
|
||||
resultado = nil
|
||||
exit_code = 0
|
||||
|
||||
begin
|
||||
resultado = yield
|
||||
exit_code = 0
|
||||
rescue StandardError => e
|
||||
@logger.error("📝 Error en ejecución AUTO: #{e.message}")
|
||||
exit_code = 1
|
||||
end
|
||||
|
||||
duracion = (Time.now - inicio).round(2)
|
||||
|
||||
# Registrar finalización
|
||||
finalizar(evento_id, exit_code: exit_code, nota: "#{duracion}s") if evento_id
|
||||
|
||||
{
|
||||
evento_id: evento_id,
|
||||
exit_code: exit_code,
|
||||
resultado: resultado,
|
||||
duracion: duracion
|
||||
}
|
||||
end
|
||||
|
||||
# Actualizar descripción de un evento
|
||||
#
|
||||
# @param evento_id [Integer] ID del evento
|
||||
# @param descripcion [String] Nueva descripción
|
||||
# @return [Boolean] true si se actualizó correctamente
|
||||
def actualizar(evento_id, descripcion:)
|
||||
@logger = ADN::Logger.new
|
||||
|
||||
sql = <<-SQL
|
||||
UPDATE bitacoras.events
|
||||
SET descripcion = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
SQL
|
||||
|
||||
BitacorasDB::BitacoraDB.ejecutar(sql, [descripcion, evento_id])
|
||||
@logger.info("📝 Evento ##{evento_id} actualizado")
|
||||
true
|
||||
rescue StandardError => e
|
||||
@logger.error("📝 Error actualizando evento: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
# Obtener información de un evento
|
||||
#
|
||||
# @param evento_id [Integer] ID del evento
|
||||
# @return [Hash, nil] Datos del evento o nil si no existe
|
||||
def obtener_evento(evento_id)
|
||||
sql = 'SELECT * FROM bitacoras.events WHERE id = $1'
|
||||
resultado = BitacorasDB::BitacoraDB.ejecutar(sql, [evento_id])
|
||||
resultado.first
|
||||
end
|
||||
|
||||
# Vincular un evento con un flujo de drones
|
||||
#
|
||||
# @param evento_id [Integer] ID del evento
|
||||
# @param flujo_id [String] ID del flujo
|
||||
# @return [Boolean] true si se vinculó correctamente
|
||||
def vincular_flujo(evento_id, flujo_id)
|
||||
@logger = ADN::Logger.new
|
||||
|
||||
evento = obtener_evento(evento_id)
|
||||
return false unless evento
|
||||
|
||||
metadata = evento['metadata'] || '{}'
|
||||
metadata_hash = JSON.parse(metadata) rescue {}
|
||||
metadata_hash['flujo_id'] = flujo_id
|
||||
|
||||
sql = <<-SQL
|
||||
UPDATE bitacoras.events
|
||||
SET metadata = $1
|
||||
WHERE id = $2
|
||||
SQL
|
||||
|
||||
BitacorasDB::BitacoraDB.ejecutar(sql, [metadata_hash.to_json, evento_id])
|
||||
@logger.info("📝 Evento ##{evento_id} vinculado al flujo #{flujo_id}")
|
||||
true
|
||||
rescue StandardError => e
|
||||
@logger.error("📝 Error vinculando flujo: #{e.message}")
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,278 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Dron::Dispatcher — Dispatcher de subcomandos para AtomicDrones
|
||||
#
|
||||
# Responsabilidad única: Despachar comandos a los módulos atómicos correctos
|
||||
# - Parsea argumentos de línea de comandos
|
||||
# - Invoca el módulo atómico correspondiente
|
||||
# - Muestra ayuda formateada
|
||||
#
|
||||
# Uso:
|
||||
# require_relative 'dron/dispatcher'
|
||||
# Dron::Dispatcher.ejecutar(ARGV)
|
||||
|
||||
require_relative '../../core/help_formatter'
|
||||
require_relative '../../core/logger'
|
||||
require_relative '../../core/colores'
|
||||
|
||||
module Dron
|
||||
class Dispatcher
|
||||
def self.ejecutar(args)
|
||||
@logger ||= ADN::Logger.new
|
||||
subcomando = args.shift
|
||||
|
||||
case subcomando
|
||||
when 'lanzar', 'run', nil
|
||||
EjecutorComando.ejecutar(args)
|
||||
when 'flota', 'list'
|
||||
VigilanteComando.flota(args)
|
||||
when 'salud', 'health'
|
||||
VigilanteComando.salud(args)
|
||||
when 'sanear', 'clean'
|
||||
SanadorComando.sanear(args)
|
||||
when 'limpiar', 'cleanup'
|
||||
SanadorComando.limpiar(args)
|
||||
when 'reintentar', 'retry'
|
||||
SanadorComando.reintentar(args)
|
||||
when 'estado', 'status'
|
||||
VigilanteComando.estado(args)
|
||||
when 'ayuda', 'help', '--help', '-h'
|
||||
mostrar_ayuda
|
||||
else
|
||||
@logger.error("❌ Subcomando desconocido: #{subcomando}")
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
def self.mostrar_ayuda
|
||||
lines = []
|
||||
lines << "#{Color::BOLD}🛸 Sistema de Drones ADN (AtomicDrones)#{Color::RESET}"
|
||||
lines << "#{Color::DIM}#{'=' * 50}#{Color::RESET}"
|
||||
lines << ""
|
||||
lines << "#{Color::CYAN}Gestión de drones atómicos especializados para automatización de tareas#{Color::RESET}"
|
||||
lines << ""
|
||||
lines << "Uso: #{Color::YELLOW}./adn/tools/run dron <subcomando> [opciones]#{Color::RESET}"
|
||||
lines << ""
|
||||
lines << "\n#{Color::BOLD}Subcomandos:#{Color::RESET}"
|
||||
lines << " #{Color::GREEN}lanzar [OPTIONS]#{Color::RESET} Lanzar tarea como dron ejecutor"
|
||||
lines << " #{Color::GREEN}flota#{Color::RESET} Dashboard compacto de la flota"
|
||||
lines << " #{Color::GREEN}salud#{Color::RESET} Health check activo (detecta zombies)"
|
||||
lines << " #{Color::GREEN}estado <ID>#{Color::RESET} Estado detallado de un dron"
|
||||
lines << " #{Color::GREEN}sanear#{Color::RESET} Sanear flota (reintentar + limpiar)"
|
||||
lines << " #{Color::GREEN}limpiar#{Color::RESET} Limpiar drones antiguos"
|
||||
lines << " #{Color::GREEN}reintentar#{Color::RESET} Re-intentar drones fallidos"
|
||||
lines << ""
|
||||
lines << "\n#{Color::BOLD}Opciones:#{Color::RESET}"
|
||||
lines << " #{Color::GREEN}--nota \"texto\"#{Color::RESET} Nota descriptiva para la bitácora"
|
||||
lines << " #{Color::GREEN}--evento ID#{Color::RESET} Vincular a evento existente"
|
||||
lines << " #{Color::GREEN}--timeout N#{Color::RESET} Timeout en segundos (default: 3600)"
|
||||
lines << " #{Color::GREEN}--dry-run#{Color::RESET} Simulación sin ejecutar"
|
||||
lines << " #{Color::GREEN}--help#{Color::RESET} Mostrar esta ayuda"
|
||||
lines << ""
|
||||
lines << "\n#{Color::BOLD}Ejemplos:#{Color::RESET}"
|
||||
lines << " #{Color::DIM}$ ./adn/tools/run dron lanzar --nota 'Backup SQL' -- vzdump 103#{Color::RESET}"
|
||||
lines << " #{Color::CYAN}Lanzar backup como dron#{Color::RESET}"
|
||||
lines << " #{Color::DIM}$ ./adn/tools/run dron flota#{Color::RESET}"
|
||||
lines << " #{Color::CYAN}Ver estado de la flota#{Color::RESET}"
|
||||
lines << " #{Color::DIM}$ ./adn/tools/run dron salud#{Color::RESET}"
|
||||
lines << " #{Color::CYAN}Health check de la flota#{Color::RESET}"
|
||||
lines << ""
|
||||
|
||||
puts lines.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Comandos encapsulados para cada módulo atómico
|
||||
# ============================================================================
|
||||
|
||||
class EjecutorComando
|
||||
def self.ejecutar(args)
|
||||
require_relative 'ejecutor'
|
||||
|
||||
# Parsear argumentos
|
||||
opciones = parsear_opciones(args)
|
||||
|
||||
if opciones[:cmd].nil?
|
||||
puts "❌ Error: Debes especificar un comando a ejecutar"
|
||||
puts ""
|
||||
puts "Uso: ./adn/tools/run dron lanzar --nota \"Nota\" -- <comando>"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Lanzar dron ejecutor
|
||||
resultado = Dron::Ejecutor.lanzar(
|
||||
cmd: opciones[:cmd],
|
||||
nota: opciones[:nota],
|
||||
evento_id: opciones[:evento_id],
|
||||
timeout: opciones[:timeout],
|
||||
flujo_id: opciones[:flujo_id]
|
||||
)
|
||||
|
||||
# Mostrar resultado
|
||||
puts "\n🛸 Resultado del Dron Ejecutor"
|
||||
puts "═" * 60
|
||||
puts " Dron ID: #{resultado[:dron_id]}"
|
||||
puts " Estado: #{resultado[:exit_code] == 0 ? '✅ Completado' : '❌ Fallido'}"
|
||||
puts " Exit Code: #{resultado[:exit_code]}"
|
||||
puts " Duración: #{resultado[:duration]}s"
|
||||
puts "═" * 60
|
||||
|
||||
exit resultado[:exit_code]
|
||||
end
|
||||
|
||||
def self.parsear_opciones(args)
|
||||
opciones = {
|
||||
nota: nil,
|
||||
evento_id: nil,
|
||||
timeout: 3600,
|
||||
flujo_id: nil,
|
||||
cmd: nil
|
||||
}
|
||||
|
||||
# Parsear opciones con nombre
|
||||
while args.first&.start_with?('--')
|
||||
arg = args.shift
|
||||
case arg
|
||||
when '--nota'
|
||||
opciones[:nota] = args.shift
|
||||
when '--evento'
|
||||
opciones[:evento_id] = args.shift.to_i
|
||||
when '--timeout'
|
||||
opciones[:timeout] = args.shift.to_i
|
||||
when '--flujo'
|
||||
opciones[:flujo_id] = args.shift
|
||||
when '--help', '-h'
|
||||
Dispatcher.mostrar_ayuda
|
||||
exit 0
|
||||
end
|
||||
end
|
||||
|
||||
# El resto es el comando a ejecutar
|
||||
opciones[:cmd] = args.join(' ') unless args.empty?
|
||||
|
||||
opciones
|
||||
end
|
||||
end
|
||||
|
||||
class VigilanteComando
|
||||
def self.flota(args)
|
||||
require_relative 'vigilante'
|
||||
|
||||
output = Dron::Vigilante.dashboard
|
||||
puts output
|
||||
end
|
||||
|
||||
def self.salud(args)
|
||||
require_relative 'vigilante'
|
||||
|
||||
diagnosticos = Dron::Vigilante.salud
|
||||
exit(diagnosticos[:saludable] ? 0 : 1)
|
||||
end
|
||||
|
||||
def self.estado(args)
|
||||
require_relative 'vigilante'
|
||||
require_relative '../../db/core/dron_db'
|
||||
|
||||
dron_id = args.first
|
||||
|
||||
if dron_id.nil?
|
||||
puts "❌ Error: Debes especificar un dron_id"
|
||||
puts "Uso: ./adn/tools/run dron estado <dron_id>"
|
||||
exit 1
|
||||
end
|
||||
|
||||
dron = ADN::DB::DronDB.obtener_dron(dron_id)
|
||||
|
||||
if dron.nil?
|
||||
puts "❌ Dron no encontrado: #{dron_id}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "\n📊 Estado del Dron"
|
||||
puts "═" * 60
|
||||
puts " ID: #{dron['dron_id']}"
|
||||
puts " Tipo: #{dron['tipo']}"
|
||||
puts " Estado: #{dron['estado']}"
|
||||
puts " Comando: #{dron['cmd']}"
|
||||
puts " Inicio: #{dron['started_at']}"
|
||||
puts " Fin: #{dron['completed_at'] || '—'}"
|
||||
puts " Heartbeat: #{dron['heartbeat']}"
|
||||
puts " Reintentos: #{dron['reintentos']}"
|
||||
puts " Exit Code: #{dron['exit_code'] || '—'}"
|
||||
puts "═" * 60
|
||||
|
||||
# Mostrar avances si existen
|
||||
require_relative '../../db/core/bitacora_db'
|
||||
sql = 'SELECT * FROM bitacoras.dron_avances WHERE dron_id = $1 ORDER BY paso ASC'
|
||||
avances = BitacorasDB::BitacoraDB.ejecutar(sql, [dron_id])
|
||||
|
||||
unless avances.empty?
|
||||
puts "\n📝 Avances:"
|
||||
avances.each do |avance|
|
||||
puts " [#{avance['estado']}] Paso #{avance['paso']}: #{avance['descripcion']}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class SanadorComando
|
||||
def self.sanear(args)
|
||||
require_relative 'sanador'
|
||||
|
||||
opciones = parsear_opciones(args)
|
||||
|
||||
resultado = Dron::Sanador.sanear(
|
||||
dias_limpieza: opciones[:dias],
|
||||
dry_run: opciones[:dry_run]
|
||||
)
|
||||
|
||||
exit(0)
|
||||
end
|
||||
|
||||
def self.limpiar(args)
|
||||
require_relative 'sanador'
|
||||
|
||||
opciones = parsear_opciones(args)
|
||||
|
||||
resultado = Dron::Sanador.limpiar_antiguos(
|
||||
dias: opciones[:dias],
|
||||
dry_run: opciones[:dry_run]
|
||||
)
|
||||
|
||||
exit(0)
|
||||
end
|
||||
|
||||
def self.reintentar(args)
|
||||
require_relative 'sanador'
|
||||
|
||||
opciones = parsear_opciones(args)
|
||||
|
||||
resultado = Dron::Sanador.reintentar_fallidos(
|
||||
dry_run: opciones[:dry_run]
|
||||
)
|
||||
|
||||
exit(0)
|
||||
end
|
||||
|
||||
def self.parsear_opciones(args)
|
||||
opciones = {
|
||||
dias: 7,
|
||||
dry_run: false
|
||||
}
|
||||
|
||||
args.each do |arg|
|
||||
case arg
|
||||
when '--dry-run'
|
||||
opciones[:dry_run] = true
|
||||
when '--dias'
|
||||
idx = args.index(arg)
|
||||
opciones[:dias] = args[idx + 1].to_i if args[idx + 1]
|
||||
end
|
||||
end
|
||||
|
||||
opciones
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,233 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Dron::Ejecutor — Módulo atómico para ejecución de comandos
|
||||
#
|
||||
# Responsabilidad única: Ejecutar un comando y reportar resultado
|
||||
# - Lanza el comando en background
|
||||
# - Registra inicio/fin en dron_logs (DB)
|
||||
# - Actualiza heartbeat cada 30s
|
||||
# - Captura output y exit_code
|
||||
#
|
||||
# Uso:
|
||||
# require_relative 'dron/ejecutor'
|
||||
# Dron::Ejecutor.lanzar(cmd: 'vzdump 103', nota: 'Backup SQL')
|
||||
|
||||
require_relative '../../db/core/dron_db'
|
||||
require_relative '../../core/constants'
|
||||
require_relative '../../core/logger'
|
||||
|
||||
module Dron
|
||||
class Ejecutor
|
||||
# Timeout por defecto para comandos (en segundos)
|
||||
DEFAULT_TIMEOUT = 3600 # 1 hora
|
||||
|
||||
# Intervalo de heartbeat (en segundos)
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
|
||||
class << self
|
||||
# Lanzar un comando como dron ejecutor
|
||||
#
|
||||
# @param cmd [String] Comando a ejecutar
|
||||
# @param nota [String, nil] Nota descriptiva para bitácora
|
||||
# @param evento_id [Integer, nil] ID de evento en bitácora (si existe)
|
||||
# @param timeout [Integer] Timeout en segundos (default: 3600)
|
||||
# @param flujo_id [String, nil] ID de orquestación (si es parte de un flujo)
|
||||
# @return [Hash] Resultado de la ejecución
|
||||
def lanzar(cmd:, nota: nil, evento_id: nil, timeout: DEFAULT_TIMEOUT, flujo_id: nil)
|
||||
@logger = ADN::Logger.new
|
||||
|
||||
# Generar ID único para este dron
|
||||
dron_id = ADN::DB::DronDB.generar_dron_id
|
||||
|
||||
# Registrar inicio en DB (dron_logs)
|
||||
ADN::DB::DronDB.registrar_inicio(
|
||||
tipo: 'ejecutor',
|
||||
cmd: cmd,
|
||||
flujo_id: flujo_id,
|
||||
metadata: { nota: nota, evento_id: evento_id, timeout: timeout }
|
||||
)
|
||||
|
||||
@logger.info("🛠️ Dron Ejecutor iniciado: #{dron_id}")
|
||||
@logger.info(" Comando: #{cmd}")
|
||||
@logger.info(" Timeout: #{timeout}s") if timeout
|
||||
|
||||
# Registrar en bitácora humana (evento ⏳)
|
||||
evento_id = registrar_inicio_bitacora(nota, cmd, evento_id) if nota
|
||||
|
||||
# Ejecutar comando en background
|
||||
resultado = ejecutar_comando(cmd, dron_id, timeout)
|
||||
|
||||
# Registrar fin en DB
|
||||
ADN::DB::DronDB.registrar_fin(dron_id, resultado[:exit_code], resultado[:output])
|
||||
|
||||
# Registrar en bitácora humana (evento ✅ o ❌)
|
||||
registrar_fin_bitacora(evento_id, resultado, nota) if evento_id
|
||||
|
||||
@logger.info("🛠️ Dron Ejecutor completado: #{dron_id}")
|
||||
@logger.info(" Estado: #{resultado[:exit_code] == 0 ? '✅ Completado' : '❌ Fallido'}")
|
||||
@logger.info(" Duración: #{resultado[:duration]}s")
|
||||
|
||||
{
|
||||
dron_id: dron_id,
|
||||
exit_code: resultado[:exit_code],
|
||||
output: resultado[:output],
|
||||
duration: resultado[:duration],
|
||||
evento_id: evento_id
|
||||
}
|
||||
rescue StandardError => e
|
||||
# Registrar fallo crítico
|
||||
ADN::DB::DronDB.registrar_fin(dron_id, 1, "Error: #{e.message}") if dron_id
|
||||
registrar_fin_bitacora(evento_id, { exit_code: 1, output: e.message, duration: 0 }, nota) if evento_id
|
||||
|
||||
@logger.error("🛠️ Dron Ejecutor falló: #{dron_id}")
|
||||
@logger.error(" Error: #{e.message}")
|
||||
|
||||
{
|
||||
dron_id: dron_id,
|
||||
exit_code: 1,
|
||||
output: e.message,
|
||||
duration: 0,
|
||||
evento_id: evento_id,
|
||||
error: e.message
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Ejecutar comando y capturar output
|
||||
#
|
||||
# @param cmd [String] Comando a ejecutar
|
||||
# @param dron_id [String] ID del dron
|
||||
# @param timeout [Integer] Timeout en segundos
|
||||
# @return [Hash] Resultado con exit_code, output, duration
|
||||
def ejecutar_comando(cmd, dron_id, timeout)
|
||||
start_time = Time.now
|
||||
output = []
|
||||
exit_code = nil
|
||||
|
||||
# Thread para actualizar heartbeat
|
||||
heartbeat_thread = Thread.new do
|
||||
loop do
|
||||
sleep(HEARTBEAT_INTERVAL)
|
||||
ADN::DB::DronDB.actualizar_heartbeat(dron_id)
|
||||
@logger.debug("💓 Heartbeat: #{dron_id}")
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
# Ejecutar comando con timeout
|
||||
Timeout.timeout(timeout) do
|
||||
# Usar Open3 para capturar output en tiempo real
|
||||
require 'open3'
|
||||
Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr|
|
||||
# Leer stdout y stderr concurrentemente
|
||||
while stdout.readline || stderr.readline
|
||||
begin
|
||||
line = stdout.readline.chomp
|
||||
output << line if line && !line.empty?
|
||||
@logger.debug(" #{line}") if line && !line.empty?
|
||||
rescue EOFError
|
||||
begin
|
||||
line = stderr.readline.chomp
|
||||
output << "[ERR] #{line}" if line && !line.empty?
|
||||
@logger.debug(" [ERR] #{line}") if line && !line.empty?
|
||||
rescue EOFError
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
exit_code = wait_thr.value.exitstatus
|
||||
end
|
||||
end
|
||||
rescue Timeout::Error
|
||||
exit_code = 124 # Código estándar para timeout
|
||||
output << "\n[TIMEOUT] Comando excedió #{timeout}s de ejecución"
|
||||
@logger.error(" [TIMEOUT] Comando excedió #{timeout}s")
|
||||
rescue StandardError => e
|
||||
exit_code = 1
|
||||
output << "\n[ERROR] #{e.message}"
|
||||
@logger.error(" [ERROR] #{e.message}")
|
||||
ensure
|
||||
# Detener thread de heartbeat
|
||||
heartbeat_thread.exit
|
||||
end
|
||||
|
||||
duration = (Time.now - start_time).round(2)
|
||||
|
||||
{
|
||||
exit_code: exit_code,
|
||||
output: output.join("\n"),
|
||||
duration: duration
|
||||
}
|
||||
end
|
||||
|
||||
# Registrar inicio en bitácora humana (evento ⏳)
|
||||
#
|
||||
# @param nota [String] Nota descriptiva
|
||||
# @param cmd [String] Comando ejecutado
|
||||
# @param evento_id [Integer, nil] ID de evento existente
|
||||
# @return [Integer] ID del evento creado/actualizado
|
||||
def registrar_inicio_bitacora(nota, cmd, evento_id = nil)
|
||||
require_relative '../../db/core/bitacora_db'
|
||||
|
||||
descripcion = "🛸 dron: #{nota}"
|
||||
|
||||
if evento_id
|
||||
# Actualizar evento existente
|
||||
BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion)
|
||||
evento_id
|
||||
else
|
||||
# Crear nuevo evento
|
||||
BitacorasDB::BitacoraDB.crear_evento(
|
||||
nodo_id: 1, # srv-ns8 por defecto (mejora: hacer configurable)
|
||||
descripcion: descripcion,
|
||||
inicio: Time.now.strftime('%H:%M'),
|
||||
estado: '⏳'
|
||||
)
|
||||
end
|
||||
rescue StandardError => e
|
||||
@logger.warn("⚠️ No se pudo registrar en bitácora: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# Registrar fin en bitácora humana (evento ✅ o ❌)
|
||||
#
|
||||
# @param evento_id [Integer] ID del evento
|
||||
# @param resultado [Hash] Resultado de la ejecución
|
||||
# @param nota [String] Nota descriptiva
|
||||
def registrar_fin_bitacora(evento_id, resultado, nota)
|
||||
require_relative '../../db/core/bitacora_db'
|
||||
|
||||
return unless evento_id
|
||||
|
||||
estado = resultado[:exit_code] == 0 ? '✅' : '❌'
|
||||
duracion = formato_duracion(resultado[:duration])
|
||||
|
||||
descripcion = "🛸 dron: #{nota} — #{estado} (#{duracion})"
|
||||
|
||||
BitacorasDB::BitacoraDB.actualizar_evento(
|
||||
evento_id,
|
||||
descripcion: descripcion,
|
||||
fin: Time.now.strftime('%H:%M')
|
||||
)
|
||||
rescue StandardError => e
|
||||
@logger.warn("⚠️ No se pudo actualizar bitácora: #{e.message}")
|
||||
end
|
||||
|
||||
# Formatear duración en segundos a formato legible
|
||||
#
|
||||
# @param segundos [Numeric] Duración en segundos
|
||||
# @return [String] Duración formateada (ej: "2min 30s")
|
||||
def formato_duracion(segundos)
|
||||
minutos = (segundos / 60).to_i
|
||||
secs = (segundos % 60).to_i
|
||||
|
||||
if minutos > 0
|
||||
"#{minutos}min #{secs}s"
|
||||
else
|
||||
"#{secs}s"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,207 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Dron::Sanador — Módulo atómico para reparación de drones
|
||||
#
|
||||
# Responsabilidad única: Reparar drones zombies/fallidos
|
||||
# - Re-intenta drones fallidos (máx 3 intentos, backoff exponencial)
|
||||
# - Limpia drones completados antiguos
|
||||
# - Notifica fallos persistentes
|
||||
#
|
||||
# Uso:
|
||||
# require_relative 'dron/sanador'
|
||||
# Dron::Sanador.reintentar_fallidos
|
||||
# Dron::Sanador.limpiar_completados
|
||||
|
||||
require_relative '../../db/core/dron_db'
|
||||
require_relative '../../core/constants'
|
||||
require_relative '../../core/logger'
|
||||
|
||||
module Dron
|
||||
class Sanador
|
||||
# Máximo de reintentos antes de marcar como fallo persistente
|
||||
MAX_REINTENTOS = 3
|
||||
|
||||
# Backoff exponencial: 1min, 2min, 4min entre reintentos
|
||||
BACKOFF_BASE = 60 # segundos
|
||||
|
||||
# Días de antigüedad para limpiar completados
|
||||
CLEANUP_DAYS = 7
|
||||
|
||||
class << self
|
||||
# Re-intentar drones fallidos (máx 3 intentos con backoff)
|
||||
#
|
||||
# @param dry_run [Boolean] Si true, solo muestra qué haría
|
||||
# @return [Hash] Resultado de la operación
|
||||
def reintentar_fallidos(dry_run: false)
|
||||
@logger = ADN::Logger.new
|
||||
@logger.info("🩹 Dron Sanador: Re-intentando drones fallidos...")
|
||||
|
||||
# Obtener drones fallidos que puedan reintentarse
|
||||
sql = <<-SQL
|
||||
SELECT * FROM bitacoras.dron_logs
|
||||
WHERE estado = 'failed'
|
||||
AND reintentos < #{MAX_REINTENTOS}
|
||||
AND cmd IS NOT NULL
|
||||
ORDER BY completed_at ASC
|
||||
SQL
|
||||
|
||||
require_relative '../../db/core/bitacora_db'
|
||||
fallidos = BitacorasDB::BitacoraDB.ejecutar(sql)
|
||||
|
||||
if fallidos.empty?
|
||||
@logger.info("🩹 No hay drones fallidos para re-intentar")
|
||||
return { reintentados: 0, omitidos: 0 }
|
||||
end
|
||||
|
||||
reintentados = 0
|
||||
omitidos = 0
|
||||
|
||||
fallidos.each do |dron|
|
||||
reintentos = dron['reintentos'].to_i
|
||||
|
||||
# Calcular tiempo de espera con backoff exponencial
|
||||
espera = BACKOFF_BASE * (2 ** reintentos) # 60s, 120s, 240s
|
||||
|
||||
# Verificar si ya pasó el tiempo de backoff
|
||||
tiempo_desde_fallo = Time.now - dron['completed_at']
|
||||
|
||||
if tiempo_desde_fallo < espera
|
||||
@logger.debug("⏳ #{dron['dron_id']} en backoff (#{tiempo_desde_fallo.round}s/#{espera}s)")
|
||||
omitidos += 1
|
||||
next
|
||||
end
|
||||
|
||||
if dry_run
|
||||
@logger.info("🩹 [DRY RUN] Re-intentaría: #{dron['dron_id']}")
|
||||
reintentados += 1
|
||||
else
|
||||
# Re-intentar el comando
|
||||
@logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{reintentos + 1}/#{MAX_REINTENTOS})")
|
||||
|
||||
# Incrementar contador de reintentos
|
||||
nuevos_reintentos = ADN::DB::DronDB.incrementar_reintentos(dron['dron_id'])
|
||||
|
||||
# Volver a lanzar el comando
|
||||
require_relative 'ejecutor'
|
||||
resultado = Dron::Ejecutor.lanzar(
|
||||
cmd: dron['cmd'],
|
||||
nota: "Reintento #{nuevos_reintentos + 1}: #{dron.dig('metadata', 'nota')}",
|
||||
flujo_id: dron['flujo_id']
|
||||
)
|
||||
|
||||
if resultado[:exit_code] == 0
|
||||
@logger.info("🩹 ✅ Reintento exitoso: #{dron['dron_id']}")
|
||||
reintentados += 1
|
||||
else
|
||||
@logger.warn("🩹 ❌ Reintento fallido: #{dron['dron_id']}")
|
||||
|
||||
# Si llegó al máximo de reintentos, notificar
|
||||
if nuevos_reintentos >= MAX_REINTENTOS
|
||||
notificar_fallo_persistente(dron)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts "\n🩹 Resultado de reintentos:"
|
||||
puts " Reintentados: #{reintentados}"
|
||||
puts " Omitidos (backoff): #{omitidos}"
|
||||
|
||||
{ reintentados: reintentados, omitidos: omitidos }
|
||||
end
|
||||
|
||||
# Limpiar drones completados/failed/zombies antiguos
|
||||
#
|
||||
# @param dias [Integer] Días de antigüedad para limpiar
|
||||
# @param dry_run [Boolean] Si true, solo muestra qué limpiaría
|
||||
# @return [Hash] Resultado de la operación
|
||||
def limpiar_antiguos(dias: CLEANUP_DAYS, dry_run: false)
|
||||
@logger = ADN::Logger.new
|
||||
@logger.info("🩹 Dron Sanador: Limpiando drones antiguos (>#{dias} días)...")
|
||||
|
||||
sql = <<-SQL
|
||||
SELECT COUNT(*) as cantidad FROM bitacoras.dron_logs
|
||||
WHERE estado IN ('completed', 'failed', 'zombie')
|
||||
AND completed_at < NOW() - INTERVAL '#{dias} days'
|
||||
SQL
|
||||
|
||||
require_relative '../../db/core/bitacora_db'
|
||||
resultado = BitacorasDB::BitacoraDB.ejecutar(sql)
|
||||
cantidad = resultado.first['cantidad'].to_i
|
||||
|
||||
if cantidad == 0
|
||||
@logger.info("🩹 No hay drones antiguos para limpiar")
|
||||
return { eliminados: 0 }
|
||||
end
|
||||
|
||||
if dry_run
|
||||
@logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos")
|
||||
puts "\n🩹 Drones a limpiar: #{cantidad}"
|
||||
return { eliminados: 0 }
|
||||
end
|
||||
|
||||
# Eliminar drones antiguos
|
||||
sql_delete = <<-SQL
|
||||
DELETE FROM bitacoras.dron_logs
|
||||
WHERE estado IN ('completed', 'failed', 'zombie')
|
||||
AND completed_at < NOW() - INTERVAL '#{dias} days'
|
||||
SQL
|
||||
|
||||
BitacorasDB::BitacoraDB.ejecutar(sql_delete)
|
||||
|
||||
@logger.info("🩹 ✅ Limpiados #{cantidad} drones antiguos")
|
||||
puts "\n🩹 Drones eliminados: #{cantidad}"
|
||||
|
||||
{ eliminados: cantidad }
|
||||
end
|
||||
|
||||
# Sanear toda la flota (reintentar + limpiar)
|
||||
#
|
||||
# @param dias_limpieza [Integer] Días para limpieza
|
||||
# @param dry_run [Boolean] Si true, modo simulación
|
||||
# @return [Hash] Resultado combinado
|
||||
def sanear(dias_limpieza: CLEANUP_DAYS, dry_run: false)
|
||||
@logger = ADN::Logger.new
|
||||
@logger.info("🩹 Dron Sanador: Iniciando saneamiento completo...")
|
||||
|
||||
resultados = {
|
||||
reintentos: reintentar_fallidos(dry_run: dry_run),
|
||||
limpieza: limpiar_antiguos(dias: dias_limpieza, dry_run: dry_run)
|
||||
}
|
||||
|
||||
puts "\n🩹 Saneamiento completado"
|
||||
puts " Reintentos: #{resultados[:reintentos][:reintentados]}"
|
||||
puts " Limpieza: #{resultados[:limpieza][:eliminados]}"
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Notificar fallo persistente (tras 3 reintentos fallidos)
|
||||
#
|
||||
# @param dron [Hash] Datos del dron con fallo persistente
|
||||
def notificar_fallo_persistente(dron)
|
||||
@logger = ADN::Logger.new
|
||||
@logger.error("🚨 FALLO PERSISTENTE: #{dron['dron_id']}")
|
||||
@logger.error(" Comando: #{dron['cmd']}")
|
||||
@logger.error(" Reintentos: #{dron['reintentos']}")
|
||||
|
||||
# Registrar en dron_avances como incidente crítico
|
||||
sql = <<-SQL
|
||||
INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado)
|
||||
VALUES ($1, -99, $2, 'critical')
|
||||
RETURNING *
|
||||
SQL
|
||||
|
||||
descripcion = "FALLO PERSISTENTE tras #{dron['reintentos']} reintentos: #{dron['cmd']&.slice(0, 100)}"
|
||||
BitacorasDB::BitacoraDB.ejecutar(sql, [dron['dron_id'], descripcion])
|
||||
|
||||
# TODO: Integrar con dron_mensajero para notificar por Slack/email
|
||||
# Dron::Mensajero.notificar(
|
||||
# tipo: 'critical',
|
||||
# mensaje: "Dron #{dron['dron_id']} falló persistentemente tras #{dron['reintentos']} reintentos",
|
||||
# contexto: dron
|
||||
# )
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,286 @@
|
||||
# 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<Hash>] 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<Hash>] 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<Hash>] 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
|
||||
Reference in New Issue
Block a user