fix(dron): Ejecutor funcional + tabla events

Cambios:
- ejecutor.rb: Corregir lectura de output con Open3
- bitacora_db.rb: Agregar métodos crear_evento/actualizar_evento (public)
- dron_db.rb: Corregir conversión de PG::Result a Hash (.first.to_h)
- vigilante.rb: Corregir formato de fecha en dashboard
- migrations/004: Crear tabla bitacoras.events para Bitácora Web

Pruebas:
- dron lanzar --evento AUTO --nota "Test" -- echo "hola" 
- dron flota 
- dron salud 

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Monla
2026-04-07 12:34:20 -03:00
co-authored by Claude Opus 4.6
parent 3d2a988951
commit be3a78c50a
5 changed files with 156 additions and 48 deletions
+16 -13
View File
@@ -27,8 +27,7 @@ module Dron
{ dron_id: dron_id, exit_code: resultado[:exit_code], duration: resultado[:duration], evento_id: evento_id } { dron_id: dron_id, exit_code: resultado[:exit_code], duration: resultado[:duration], evento_id: evento_id }
rescue StandardError => e rescue StandardError => e
Base.logger.error("🛠️ Dron Ejecutor falló: #{e.message}") Base.logger.error("🛠️ Dron Ejecutor falló: #{e.message}")
ADN::DB::DronDB.registrar_fin(dron_id, 1, e.message) if dron_id { dron_id: nil, exit_code: 1, output: e.message, duration: 0, error: e.message }
{ dron_id: dron_id, exit_code: 1, output: e.message, duration: 0, error: e.message }
end end
private private
@@ -36,6 +35,7 @@ module Dron
def ejecutar_comando(cmd, dron_id, timeout) def ejecutar_comando(cmd, dron_id, timeout)
start_time = Time.now start_time = Time.now
output = [] output = []
exit_code = nil
heartbeat_thread = Thread.new do heartbeat_thread = Thread.new do
loop do loop do
@@ -45,14 +45,13 @@ module Dron
end end
begin begin
Timeout.timeout(timeout) do require 'open3'
require 'open3' Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr|
Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr| # Leer stdout y stderr en threads separados
output << stdout.readline.chomp while (line = stdout.readline) && output << line out_thread = Thread.new { output << stdout.read }
output << stderr.readline.chomp while (line = stderr.readline) && output << "[ERR] #{line}" err_thread = Thread.new { output << stderr.read }
rescue EOFError out_thread.join
break err_thread.join
end
exit_code = wait_thr.value.exitstatus exit_code = wait_thr.value.exitstatus
end end
rescue Timeout::Error rescue Timeout::Error
@@ -65,7 +64,7 @@ module Dron
heartbeat_thread.exit heartbeat_thread.exit
end end
{ exit_code: exit_code, output: output.join("\n"), duration: (Time.now - start_time).round(2) } { exit_code: exit_code || 1, output: output.join("\n").strip, duration: (Time.now - start_time).round(2) }
end end
def registrar_bitacora(nota, evento_id, tipo, resultado = nil) def registrar_bitacora(nota, evento_id, tipo, resultado = nil)
@@ -74,11 +73,15 @@ module Dron
if tipo == 'inicio' if tipo == 'inicio'
descripcion = "🛸 dron: #{nota}" descripcion = "🛸 dron: #{nota}"
BitacorasDB::BitacoraDB.crear_evento(nodo_id: 1, descripcion: descripcion, inicio: Time.now.strftime('%H:%M'), estado: '⏳') BitacorasDB::BitacoraDB.with_connection do |db|
db.crear_evento(nodo_id: 1, descripcion: descripcion, inicio: Time.now.strftime('%H:%M'), estado: '⏳')
end
else else
estado = resultado[:exit_code] == 0 ? '✅' : '❌' estado = resultado[:exit_code] == 0 ? '✅' : '❌'
descripcion = "🛸 dron: #{nota}#{estado} (#{Base.formato_duracion(resultado[:duration])})" descripcion = "🛸 dron: #{nota}#{estado} (#{Base.formato_duracion(resultado[:duration])})"
BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion, fin: Time.now.strftime('%H:%M')) BitacorasDB::BitacoraDB.with_connection do |db|
db.actualizar_evento(evento_id, descripcion: descripcion, fin: Time.now.strftime('%H:%M'))
end
end end
rescue StandardError => e rescue StandardError => e
Base.logger.warn("⚠️ Error en bitácora: #{e.message}") Base.logger.warn("⚠️ Error en bitácora: #{e.message}")
+2 -1
View File
@@ -105,7 +105,8 @@ module Dron
output << " #{"ID".ljust(25)} | #{"Tipo".ljust(15)} | #{"Inicio".ljust(10)} | Cmd" output << " #{"ID".ljust(25)} | #{"Tipo".ljust(15)} | #{"Inicio".ljust(10)} | Cmd"
output << " " + "" * 70 output << " " + "" * 70
activos.each do |d| activos.each do |d|
output << " #{d['dron_id'].slice(0, 24).ljust(25)} | #{d['tipo'].to_s.slice(0, 14).ljust(15)} | #{d['started_at'].strftime('%H:%M:%S').ljust(10)} | #{d['cmd'].to_s.slice(0, 30)}" inicio = d['started_at'].is_a?(Time) ? d['started_at'].strftime('%H:%M:%S') : d['started_at'].to_s.slice(11, 8)
output << " #{d['dron_id'].slice(0, 24).ljust(25)} | #{d['tipo'].to_s.slice(0, 14).ljust(15)} | #{inicio.ljust(10)} | #{d['cmd'].to_s.slice(0, 30)}"
end end
end end
output.join("\n") output.join("\n")
+68 -29
View File
@@ -984,38 +984,77 @@ module BitacorasDB
end end
end end
# Convierte un resultado PG a Hash # Convierte un resultado PG a Hash
def result_to_hash(result) def result_to_hash(result)
return {} if result.ntuples.zero? return {} if result.ntuples.zero?
result.first.to_h result.first.to_h
end end
# Ejecuta hooks personalizados después de operaciones de entrada # Ejecuta hooks personalizados después de operaciones de entrada
# Esto implementa el concepto de DB Hooks para migrar triggers.yml a capa de datos def ejecutar_hooks_entrada(entrada, accion)
# if entrada[:descripcion]&.match?(/\[P2604\]/)
# @param entrada [Hash] La entrada que fue creada o actualizada proyecto_codigo = entrada[:descripcion].match(/\[P2604\.(\d+)\.(\d+)\.(\d+)\]/)&.to_s
# @param accion [Symbol] :crear o :actualizar if proyecto_codigo
def ejecutar_hooks_entrada(entrada, accion) proyecto_base = proyecto_codigo.split('.')[0..1].join('.')
# Hook para proyectos con etiqueta [P2604] proyecto = find_proyecto_by_codigo(proyecto_base)
if entrada[:descripcion]&.match?(/\[P2604\]/) if proyecto
proyecto_codigo = entrada[:descripcion].match(/\[P2604\.(\d+)\.(\d+)\.(\d+)\]/)&.to_s @logger&.info("Hook ejecutado: Entrada #{accion} para proyecto #{proyecto_base} detectada")
if proyecto_codigo
# Normalizar formato: P2604.05.01 -> P2604
proyecto_base = proyecto_codigo.split('.')[0..1].join('.')
# Actualizar el manifiesto del proyecto en la base de datos
proyecto = find_proyecto_by_codigo(proyecto_base)
if proyecto
# Aquí podríamos actualizar algún campo del proyecto basado en la entrada
# Por ahora, solo registramos que se debería actualizar
@logger&.info("Hook ejecutado: Entrada #{accion} para proyecto #{proyecto_base} detectada")
# En una implementación completa, aquí actualizaríamos el manifiesto del proyecto
end
end end
end end
# Otros hooks podrían ir aquí...
# Por ejemplo, hooks para sincronización con nodos, etc.
end end
end
public
# Crear un evento en la tabla events (visible en Bitácora Web)
def crear_evento(nodo_id:, descripcion:, estado: '⏳', inicio: nil, fin: nil, modo: 'A', metadata: nil)
inicio ||= Time.now.strftime('%H:%M')
query = <<~SQL
INSERT INTO bitacoras.events (nodo_id, descripcion, estado, inicio, fin, modo, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
SQL
result = @connection.exec_params(query, [nodo_id, descripcion, estado, inicio, fin, modo, metadata&.to_json])
result.first.to_h
end
# Actualizar un evento existente
def actualizar_evento(id, descripcion: nil, estado: nil, fin: nil)
set_clauses = []
values = []
i = 1
if descripcion
set_clauses << "descripcion = $#{i}"
values << descripcion
i += 1
end
if estado
set_clauses << "estado = $#{i}"
values << estado
i += 1
end
if fin
set_clauses << "fin = $#{i}"
values << fin
i += 1
end
return nil if set_clauses.empty?
set_clauses << "updated_at = NOW()"
query = <<~SQL
UPDATE bitacoras.events
SET #{set_clauses.join(', ')}
WHERE id = $#{i}
RETURNING *
SQL
values << id
result = @connection.exec_params(query, values)
result.first.to_h
end
end end
end end
+5 -5
View File
@@ -56,7 +56,7 @@ module ADN
dron_id = generar_dron_id dron_id = generar_dron_id
params = [dron_id, tipo, ESTADOS[:running], cmd, flujo_id, metadata.to_json] params = [dron_id, tipo, ESTADOS[:running], cmd, flujo_id, metadata.to_json]
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, params) } resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, params) }.first.to_h
# Actualizar heartbeat inicial # Actualizar heartbeat inicial
actualizar_heartbeat(dron_id) actualizar_heartbeat(dron_id)
@@ -88,7 +88,7 @@ module ADN
RETURNING * RETURNING *
SQL SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [estado, exit_code, output, dron_id]) } resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [estado, exit_code, output, dron_id]) }.first.to_h
# Actualizar métricas diarias # Actualizar métricas diarias
actualizar_metricas(dron_id, estado) actualizar_metricas(dron_id, estado)
@@ -118,7 +118,7 @@ module ADN
# @return [Hash, nil] Datos del dron o nil si no existe # @return [Hash, nil] Datos del dron o nil si no existe
def obtener_dron(dron_id) def obtener_dron(dron_id)
sql = 'SELECT * FROM bitacoras.dron_logs WHERE dron_id = $1' sql = 'SELECT * FROM bitacoras.dron_logs WHERE dron_id = $1'
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) } resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) }.first.to_h
resultado.first resultado.first
end end
@@ -176,7 +176,7 @@ module ADN
RETURNING reintentos RETURNING reintentos
SQL SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) } resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) }.first.to_h
resultado.first['reintentos'].to_i resultado.first['reintentos'].to_i
end end
@@ -229,7 +229,7 @@ module ADN
# @return [Hash, nil] Métricas o nil si no existen # @return [Hash, nil] Métricas o nil si no existen
def obtener_metricas(fecha:, tipo:) def obtener_metricas(fecha:, tipo:)
sql = 'SELECT * FROM bitacoras.dron_metricas WHERE fecha = $1 AND dron_tipo = $2' sql = 'SELECT * FROM bitacoras.dron_metricas WHERE fecha = $1 AND dron_tipo = $2'
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [fecha, tipo]) } resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [fecha, tipo]) }.first.to_h
resultado.first resultado.first
end end
@@ -0,0 +1,65 @@
# frozen_string_literal: true
# Migración 004: Crear tabla events para Bitácora Web
# ====================================================
# Esta tabla almacena eventos visibles en la Bitácora Web
# Es el medio de comunicación donde los drones dejan registro de su actividad
require_relative '../core/bitacora_db'
module BitacorasDB
module Migrations
class CreateEventsTable
def self.up
BitacorasDB::BitacoraDB.with_connection do |db|
# Crear tabla
db.instance_variable_get(:@connection).exec(<<~SQL)
CREATE TABLE IF NOT EXISTS bitacoras.events (
id SERIAL PRIMARY KEY,
nodo_id INTEGER NOT NULL REFERENCES bitacoras.nodos(id),
descripcion TEXT NOT NULL,
estado VARCHAR(10) NOT NULL DEFAULT '⏳',
inicio VARCHAR(10) NOT NULL,
fin VARCHAR(10),
modo VARCHAR(10) DEFAULT 'A',
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
SQL
# Crear índices
db.instance_variable_get(:@connection).exec("CREATE INDEX IF NOT EXISTS idx_events_nodo ON bitacoras.events(nodo_id)")
db.instance_variable_get(:@connection).exec("CREATE INDEX IF NOT EXISTS idx_events_estado ON bitacoras.events(estado)")
db.instance_variable_get(:@connection).exec("CREATE INDEX IF NOT EXISTS idx_events_inicio ON bitacoras.events(inicio)")
db.instance_variable_get(:@connection).exec("CREATE INDEX IF NOT EXISTS idx_events_metadata ON bitacoras.events USING GIN(metadata)")
# Enable RLS
db.instance_variable_get(:@connection).exec("ALTER TABLE bitacoras.events ENABLE ROW LEVEL SECURITY")
# Policies
db.instance_variable_get(:@connection).exec("DROP POLICY IF EXISTS events_insert ON bitacoras.events")
db.instance_variable_get(:@connection).exec("CREATE POLICY events_insert ON bitacoras.events FOR INSERT WITH CHECK (true)")
db.instance_variable_get(:@connection).exec("DROP POLICY IF EXISTS events_select ON bitacoras.events")
db.instance_variable_get(:@connection).exec("CREATE POLICY events_select ON bitacoras.events FOR SELECT USING (true)")
db.instance_variable_get(:@connection).exec("DROP POLICY IF EXISTS events_update ON bitacoras.events")
db.instance_variable_get(:@connection).exec("CREATE POLICY events_update ON bitacoras.events FOR UPDATE USING (true)")
end
puts "✅ Tabla bitacoras.events creada"
end
def self.down
BitacorasDB::BitacoraDB.with_connection do |db|
db.instance_variable_get(:@connection).exec("DROP TABLE IF EXISTS bitacoras.events CASCADE")
end
puts "✅ Tabla bitacoras.events eliminada"
end
end
end
end
if __FILE__ == $PROGRAM_NAME
BitacorasDB::Migrations::CreateEventsTable.up
end