Refactor(ADN): Optimización de la arquitectura y reparación de subcomandos

- Reestructuración del CLI en módulos discretos (core/, comando/, config/, clientes/)
- Migración del punto de entrada tools/adn.rb hacia el launcher ejecutable tools/adn/run
- Simplificación de nombres de comandos (remoción prefijo subcomando_)
- Corrección de rutas base transversales (PROJECT_ROOT, BITACORAS_DIR)
- Reescritura integral de sentencias require_relative
- Restauración de sintaxis en archivo sync.rb truncado
This commit is contained in:
Ricardo Monla
2026-03-07 09:45:21 -03:00
parent 39e3e130e6
commit c4c761e408
13 changed files with 365 additions and 75 deletions
+191
View File
@@ -0,0 +1,191 @@
# frozen_string_literal: true
require 'optparse'
require 'fileutils'
require 'date'
module ADN
class SubcomandoBackup
def initialize(args, logger)
@args = args
@logger = logger
@opciones = { modo: 'stop', storage: 'local', compress: 'gzip' }
end
def ejecutar
parser = OptionParser.new do |opts|
opts.banner = "Uso: ./tools/adn/run backup <nodo> [OPCIONES]"
opts.separator ""
opts.separator "Ejecuta un backup remoto siguiendo el protocolo ADN"
opts.separator ""
opts.separator "Opciones:"
opts.on("--mode MODO", "Modo de backup (stop, snapshot, suspend. Default: stop)") do |m|
@opciones[:modo] = m
end
opts.on("-h", "--help", "Mostrar esta ayuda") do
puts opts
exit
end
end
begin
parser.parse!(@args)
rescue OptionParser::InvalidOption => e
@logger.error("Opción inválida: #{e.message}")
puts parser
exit 1
end
if @args.empty?
@logger.error("Se requiere nombre de nodo")
puts parser
exit 1
end
nodo = @args[0]
ejecutar_backup(nodo)
end
private
def ejecutar_backup(nodo)
@logger.info("Iniciando flujo ADN-Backup para nodo: #{nodo}")
# 1. Obtener contexto del nodo
datos = obtener_contexto_nodo(nodo)
unless datos
@logger.error("No se pudo determinar el contexto técnico para #{nodo}")
exit 1
end
@logger.info("Contexto validado: VM #{datos[:vmid]} en #{datos[:host_name]} (#{datos[:host_ip]})")
# 2. Registrar inicio en bitácora (Bitácora-First)
registrar_inicio_bitacora(nodo, datos)
# 3. Ejecución segura vía ns8-candados
@logger.info("Solicitando autorización y ejecutando backup remoto...")
comando_vzdump = "vzdump #{datos[:vmid]} --mode #{@opciones[:modo]} --storage #{@opciones[:storage]} --compress #{@opciones[:compress]}"
cmd_seguro = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{datos[:host_ip]} \"#{comando_vzdump}\""
# Usamos el binario de ruby y la ruta absoluta para ns8-candados
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
# Ejecutar con ns8-candados run
# Nota: el comando 'authorize' debe haberse ejecutado antes o lo incluimos
puts "#{Color::DIM}🔓 Abriendo candado para sesión de backup...#{Color::RESET}"
system("ruby #{candados_path} authorize > /dev/null 2>&1")
success = system("ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}'")
# 4. Finalizar y cerrar
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
if success
@logger.exito("Backup de #{nodo} completado exitosamente.")
registrar_fin_bitacora(nodo, true)
else
@logger.error("La ejecución del backup falló.")
registrar_fin_bitacora(nodo, false)
exit 1
end
end
def obtener_contexto_nodo(nombre_nodo)
ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre_nodo}.md")
unless File.exist?(ruta_nodo)
@logger.error("No existe el archivo de nodo: #{ruta_nodo}")
return nil
end
contenido = File.read(ruta_nodo)
# Buscamos VMID (puede decir "VM ID: 100" o "vmid: 100")
vmid = contenido.match(/(?:VM ID|vmid):\s*(\d+)/i)&.[](1)
# Buscamos el host (especificamente el link o nombre entre backticks)
# Formato: - **Padre/Host**: `srv-dasu`
host_name = contenido.match(/Padre\/Host\*\*:?\s*`([^`]+)`/)&.[](1)
host_name ||= contenido.match(/Host\*\*:?\s*`([^`]+)`/)&.[](1)
unless vmid && host_name
@logger.error("Contexto incompleto en #{nombre_nodo}.md: VMID=#{vmid}, Host=#{host_name}")
return nil
end
# Buscar IP del host
ruta_host = File.join(ADN::NODOS_DIR, "#{host_name}.md")
host_ip = nil
if File.exist?(ruta_host)
c_host = File.read(ruta_host)
# IP en tabla: | IP | `10.0.10.1` |
host_ip ||= c_host.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1)
# IP en lista: - **IP**: `10.0.10.1`
host_ip ||= c_host.match(/IP\*\*:?\s*`([^`]+)`/)&.[](1)
# IP simple: IP: 10.0.10.1
host_ip ||= c_host.match(/IP:\s*([\d\.]+)/)&.[](1)
end
unless host_ip
@logger.error("No se encontró IP para el host #{host_name}")
return nil
end
if host_ip && host_ip.include?('/')
host_ip = host_ip.split('/')[0]
end
{ vmid: vmid, host_name: host_name, host_ip: host_ip }
end
def registrar_inicio_bitacora(nodo, datos)
fecha = Date.today.iso8601
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
return unless File.exist?(ruta)
hora = Time.now.strftime('%H:%M')
# Si ya existe la sección del nodo, añadimos la línea. Si no, creamos la sección.
contenido = File.read(ruta)
entrada = <<~ENTRADA
### #{nodo}
#### ⏳ - BKP-ADN - Backup Autónomo (Protocolo ADN)
Ejecución de backup gestionada por `tools/adn.rb` para la VM #{datos[:vmid]} en el host #{datos[:host_name]}.
| I | F | Descripción | E |
| :--- | :--- | :--- | :--- |
| #{hora} | | Inicio de backup remoto vía SSH (Seguro). | [R] ⏳ |
ENTRADA
unless contenido.include?("### #{nodo}")
# Insertar antes del footer de la IA
nuevo_contenido = contenido.sub(/<!-- 🤖 PREMISAS/, "#{entrada}\n\n<!-- 🤖 PREMISAS")
File.write(ruta, nuevo_contenido)
end
end
def registrar_fin_bitacora(nodo, exito)
fecha = Date.today.iso8601
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
return unless File.exist?(ruta)
hora = Time.now.strftime('%H:%M')
estado = exito ? '✅' : '⚠️'
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
contenido = File.read(ruta)
# Actualizar la línea de tiempo
linea_nueva = "| 08:31 | #{hora} | #{desc} | [R] #{estado} |"
# Buscamos la tarea BKP-ADN
nuevo_contenido = contenido.gsub(/#### ⏳ - BKP-ADN/, "#### #{estado} - BKP-ADN")
nuevo_contenido = nuevo_contenido.gsub(/\| \d{2}:\d{2} \| \| Inicio de backup remoto.*/, linea_nueva)
File.write(ruta, nuevo_contenido)
end
end
end
+936
View File
@@ -0,0 +1,936 @@
# frozen_string_literal: true
# tools/adn/subcomando_db.rb — Subcomando para operaciones con base de datos dtic-BITACORAs
# ==============================================================================
# Este subcomando integra las operaciones de base de datos PostgreSQL con la CLI ADN.
# Proporciona comandos para gestión de bitácoras, nodos, temas y entradas directamente
# en la base de datos, permitiendo la migración gradual desde archivos .md.
#
# Comandos disponibles:
# • adn db salud:bd — Verificar conexión y estado de la BD
# • adn db bitacora:crear [FECHA] — Crear nueva bitácora diaria
# • adn db bitacora:listar [--limit N] — Listar bitácoras existentes
# • adn db evento:crear [OPCIONES] — Crear nueva entrada cronológica
# • adn db evento:listar [OPCIONES] — Listar entradas con filtros
# • adn db nodo:crear [OPCIONES] — Crear nuevo nodo (servidor/VM/PC)
# • adn db nodo:listar [OPCIONES] — Listar nodos del sistema
# • adn db migrar:md [FECHA] — Migrar bitácora .md a base de datos
# • adn db estadisticas — Mostrar estadísticas del sistema
#
# Uso:
# ./tools/adn/run db [SUBCOMANDO] [OPCIONES]
require 'optparse'
require 'date'
require 'time'
# Cargar módulo BitacorasDB
begin
require_relative '../../bitacoras-db/core/bitacora_db'
require_relative '../../bitacoras-db/importers/md_importer'
rescue LoadError => e
# Fallback para desarrollo
puts "⚠ Advertencia: No se pudo cargar BitacoraDB o MDImporter: #{e.message}"
puts " Asegúrate de que los archivos en tools/bitacoras-db/ existen"
end
module ADN
class SubcomandoDB
# Inicializa el subcomando con argumentos y logger
#
# @param args [Array<String>] Argumentos de línea de comandos
# @param logger [ADNLogger] Instancia de logger para registro
def initialize(args, logger)
@args = args
@logger = logger
@config = cargar_configuracion
end
# Ejecuta el subcomando según los argumentos proporcionados
def ejecutar
if @args.empty? || @args[0] == 'help'
mostrar_ayuda
return
end
subcomando = @args[0]
sub_args = @args[1..-1] || []
case subcomando
when 'salud:bd'
comando_salud_bd
when 'bitacora:crear'
comando_bitacora_crear(sub_args)
when 'bitacora:listar'
comando_bitacora_listar(sub_args)
when 'evento:crear'
comando_evento_crear(sub_args)
when 'evento:listar'
comando_evento_listar(sub_args)
when 'nodo:crear'
comando_nodo_crear(sub_args)
when 'nodo:listar'
comando_nodo_listar(sub_args)
when 'migrar:md'
comando_migrar_md(sub_args)
when 'estadisticas'
comando_estadisticas
when 'backup'
comando_backup(sub_args)
else
@logger.error("Subcomando desconocido: #{subcomando}")
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
puts ""
mostrar_ayuda
exit 1
end
rescue Interrupt
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
@logger.info("Operación de base de datos interrumpida por usuario")
exit 130
rescue => e
@logger.error("Error en operación de base de datos: #{e.message}", { backtrace: e.backtrace })
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
private
# ─── COMANDOS PRINCIPALES ──────────────────────────────────────────────
# Comando: salud:bd - Verificar conexión y estado de la base de datos
def comando_salud_bd
puts "#{Color::CYAN}🔍 Verificando salud de la base de datos dtic-BITACORAs...#{Color::RESET}"
puts ""
begin
BitacorasDB::BitacoraDB.with_connection(env: :development) do |db|
salud = db.health_check
case salud[:status]
when 'healthy'
puts "#{Color::GREEN}✅ Conexión a PostgreSQL establecida#{Color::RESET}"
puts ""
puts " #{Color::BOLD}Versión PostgreSQL:#{Color::RESET} #{salud[:postgres_version].split(',')[0]}"
puts " #{Color::BOLD}Bitácoras en BD:#{Color::RESET} #{salud[:bitacoras_count]}"
puts " #{Color::BOLD}Servidor:#{Color::RESET} #{salud[:connection][:host]}:#{salud[:connection][:port]}"
puts " #{Color::BOLD}Base de datos:#{Color::RESET} #{salud[:connection][:database]}"
puts " #{Color::BOLD}Timestamp:#{Color::RESET} #{salud[:timestamp]}"
when 'disconnected'
puts "#{Color::RED}❌ No hay conexión activa a la base de datos#{Color::RESET}"
puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}"
when 'unhealthy'
puts "#{Color::YELLOW}⚠ Problema de salud en la base de datos#{Color::RESET}"
puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}"
end
end
# Verificar contenedor Docker
puts ""
puts "#{Color::CYAN}🔍 Verificando contenedor Docker...#{Color::RESET}"
if system("docker ps --filter name=postgres --format '{{.Names}}' | grep -q postgres")
puts "#{Color::GREEN}✅ Contenedor PostgreSQL en ejecución#{Color::RESET}"
else
puts "#{Color::YELLOW}⚠ Contenedor PostgreSQL no encontrado#{Color::RESET}"
puts " Ejecuta: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
end
rescue PG::ConnectionBad => e
puts "#{Color::RED}❌ Error de conexión a PostgreSQL#{Color::RESET}"
puts " #{e.message}"
puts ""
puts "#{Color::YELLOW}Solución:#{Color::RESET}"
puts " 1. Verifica que el contenedor esté en ejecución:"
puts " #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
puts " 2. Espera 10-20 segundos para que PostgreSQL inicie"
puts " 3. Verifica credenciales en tools/config/database.yml"
rescue => e
puts "#{Color::RED}❌ Error inesperado#{Color::RESET}"
puts " #{e.message}"
end
end
# Comando: bitacora:crear [FECHA] - Crear nueva bitácora diaria
def comando_bitacora_crear(args)
opts = { fecha: Date.today, resumen: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db bitacora:crear [FECHA] [OPCIONES]"
opt.on("--resumen TEXTO", "Resumen inicial de la bitácora") do |r|
opts[:resumen] = r
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
# Si queda un argumento sin procesar, asumimos que es la fecha
opts[:fecha] = Date.parse(args[0]) if args[0]
rescue OptionParser::InvalidOption, Date::Error => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📝 Creando bitácora para #{opts[:fecha]}...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
begin
bitacora = db.create_bitacora(fecha: opts[:fecha], resumen: opts[:resumen])
puts "#{Color::GREEN}✅ Bitácora creada exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}ID:#{Color::RESET} #{bitacora['id']}"
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{bitacora['fecha']}"
puts " #{Color::BOLD}Resumen:#{Color::RESET} #{bitacora['resumen'] || '(sin resumen)'}"
puts " #{Color::BOLD}Creada:#{Color::RESET} #{bitacora['created_at']}"
rescue PG::UniqueViolation
puts "#{Color::YELLOW}⚠ Ya existe una bitácora para la fecha #{opts[:fecha]}#{Color::RESET}"
puts " Usa #{Color::DIM}adn db bitacora:listar#{Color::RESET} para ver bitácoras existentes"
end
end
end
# Comando: bitacora:listar [OPCIONES] - Listar bitácoras existentes
def comando_bitacora_listar(args)
opts = { limit: 10, offset: 0, desde: nil, hasta: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db bitacora:listar [OPCIONES]"
opt.on("--limit N", Integer, "Número máximo de resultados (default: 10)") do |n|
opts[:limit] = n
end
opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n|
opts[:offset] = n
end
opt.on("--desde FECHA", "Filtrar desde fecha (YYYY-MM-DD)") do |d|
opts[:desde] = d
end
opt.on("--hasta FECHA", "Filtrar hasta fecha (YYYY-MM-DD)") do |h|
opts[:hasta] = h
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📋 Listando bitácoras...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
bitacoras = db.list_bitacoras(
limit: opts[:limit],
offset: opts[:offset],
desde: opts[:desde],
hasta: opts[:hasta]
)
if bitacoras.empty?
puts "#{Color::YELLOW}⚠ No se encontraron bitácoras#{Color::RESET}"
return
end
puts ""
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Fecha".ljust(12)} #{"Resumen".ljust(40)} #{"Creada".ljust(20)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 12} #{"" * 40} #{"" * 20}"
bitacoras.each do |b|
fecha = b['fecha']
resumen = b['resumen'] || ''
resumen = resumen[0..37] + '...' if resumen.length > 40
creada = Time.parse(b['created_at']).strftime('%Y-%m-%d %H:%M')
puts "#{b['id'].to_s.ljust(5)} #{fecha.ljust(12)} #{resumen.ljust(40)} #{creada.ljust(20)}"
end
puts ""
puts "Total: #{bitacoras.length} bitácoras"
end
end
# Comando: evento:crear [OPCIONES] - Crear nueva entrada cronológica
def comando_evento_crear(args)
opts = {
inicio: nil,
descripcion: nil,
fin: nil,
estado: '⏳',
modo: 'P',
es_ia: false,
nodo: nil,
tema: nil
}
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db evento:crear [OPCIONES]"
opt.on("--inicio HH:MM", "Hora de inicio (requerido)") do |i|
opts[:inicio] = i
end
opt.on("--descripcion TEXTO", "Descripción de la actividad (requerido)") do |d|
opts[:descripcion] = d
end
opt.on("--fin HH:MM", "Hora de fin (opcional)") do |f|
opts[:fin] = f
end
opt.on("--estado ESTADO", "Estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
opts[:estado] = e
end
opt.on("--modo MODO", "Modo (P=Presencial, R=Remoto)") do |m|
opts[:modo] = m
end
opt.on("--nodo NOMBRE", "Nombre del nodo (servidor/VM)") do |n|
opts[:nodo] = n
end
opt.on("--ia", "Marcar como generado por IA") do
opts[:es_ia] = true
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
# Validaciones básicas
unless opts[:inicio] && opts[:descripcion]
puts "#{Color::RED}✗ Error: --inicio y --descripcion son requeridos#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📝 Creando nueva entrada...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
# Buscar nodo por nombre si se especificó
nodo_id = nil
if opts[:nodo]
nodo = db.find_nodo_by_nombre(opts[:nodo])
if nodo
nodo_id = nodo['id']
puts " #{Color::DIM}Nodo encontrado: #{opts[:nodo]} (ID: #{nodo_id})#{Color::RESET}"
else
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo]}' no encontrado#{Color::RESET}"
puts " Creando entrada sin nodo asociado"
end
end
# Buscar bitácora para hoy (o crearla)
fecha_hoy = Date.today.to_s
bitacora = db.find_bitacora_by_fecha(fecha_hoy)
if bitacora.nil?
puts " #{Color::DIM}Creando bitácora para hoy...#{Color::RESET}"
bitacora = db.create_bitacora(fecha: fecha_hoy)
end
bitacora_id = bitacora['id']
# Crear la entrada
entrada = db.create_entrada(
inicio: opts[:inicio],
descripcion: opts[:descripcion],
fin: opts[:fin],
estado: opts[:estado],
modo: opts[:modo],
es_ia: opts[:es_ia],
bitacora_id: bitacora_id,
nodo_id: nodo_id,
tema_id: nil # Por ahora sin tema
)
puts "#{Color::GREEN}✅ Entrada creada exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}ID:#{Color::RESET} #{entrada['id']}"
puts " #{Color::BOLD}Inicio:#{Color::RESET} #{entrada['inicio']}"
puts " #{Color::BOLD}Fin:#{Color::RESET} #{entrada['fin'] || '(sin fin)'}"
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{entrada['descripcion']}"
puts " #{Color::BOLD}Estado:#{Color::RESET} #{entrada['estado']}"
puts " #{Color::BOLD}Modo:#{Color::RESET} #{entrada['modo'] == 'P' ? 'Presencial' : 'Remoto'}"
puts " #{Color::BOLD}IA:#{Color::RESET} #{entrada['es_ia'] ? 'Sí' : 'No'}"
puts " #{Color::BOLD}Bitácora:#{Color::RESET} #{fecha_hoy} (ID: #{bitacora_id})"
end
end
# Comando: evento:listar [OPCIONES] - Listar entradas con filtros
def comando_evento_listar(args)
opts = { limit: 20, offset: 0, nodo: nil, estado: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db evento:listar [OPCIONES]"
opt.on("--limit N", Integer, "Número máximo de resultados (default: 20)") do |n|
opts[:limit] = n
end
opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n|
opts[:offset] = n
end
opt.on("--nodo NOMBRE", "Filtrar por nombre de nodo") do |n|
opts[:nodo] = n
end
opt.on("--estado ESTADO", "Filtrar por estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
opts[:estado] = e
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📋 Listando entradas...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
# Obtener ID de nodo si se especificó nombre
nodo_id = nil
if opts[:nodo]
nodo = db.find_nodo_by_nombre(opts[:nodo])
if nodo
nodo_id = nodo['id']
puts " #{Color::DIM}Filtrando por nodo: #{opts[:nodo]} (ID: #{nodo_id})#{Color::RESET}"
else
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo]}' no encontrado#{Color::RESET}"
end
end
entradas = db.list_entradas(
nodo_id: nodo_id,
estado: opts[:estado],
limit: opts[:limit],
offset: opts[:offset]
)
if entradas.empty?
puts "#{Color::YELLOW}⚠ No se encontraron entradas#{Color::RESET}"
return
end
puts ""
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(8)} #{"Fin".ljust(8)} #{"Nodo".ljust(15)} #{"Estado".ljust(3)} #{"Descripción".ljust(40)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 8} #{"" * 8} #{"" * 15} #{"" * 3} #{"" * 40}"
entradas.each do |e|
id = e['id'].to_s
inicio = e['inicio'][0..7] # HH:MM:SS -> HH:MM
fin = e['fin'] ? e['fin'][0..7] : '--:--'
nodo = e['nodo_nombre'] || '(sin nodo)'
nodo = nodo[0..13] + '...' if nodo.length > 15
estado = e['estado']
desc = e['descripcion']
desc = desc[0..37] + '...' if desc.length > 40
puts "#{id.ljust(5)} #{inicio.ljust(8)} #{fin.ljust(8)} #{nodo.ljust(15)} #{estado.ljust(3)} #{desc.ljust(40)}"
end
puts ""
puts "Total: #{entradas.length} entradas"
end
end
# Comando: nodo:crear [OPCIONES] - Crear nuevo nodo
def comando_nodo_crear(args)
opts = { nombre: nil, ip: nil, tipo: 'servidor', descripcion: nil, activo: true }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db nodo:crear [OPCIONES]"
opt.on("--nombre NOMBRE", "Nombre único del nodo (requerido)") do |n|
opts[:nombre] = n
end
opt.on("--ip DIRECCION", "Dirección IP (opcional)") do |i|
opts[:ip] = i
end
opt.on("--tipo TIPO", "Tipo (servidor, hipervisor, vm, pc)") do |t|
opts[:tipo] = t
end
opt.on("--descripcion TEXTO", "Descripción del nodo") do |d|
opts[:descripcion] = d
end
opt.on("--inactivo", "Crear nodo como inactivo") do
opts[:activo] = false
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
unless opts[:nombre]
puts "#{Color::RED}✗ Error: --nombre es requerido#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}🖥️ Creando nodo '#{opts[:nombre]}'...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
begin
nodo = db.create_nodo(
nombre: opts[:nombre],
ip: opts[:ip],
tipo: opts[:tipo],
descripcion: opts[:descripcion],
activo: opts[:activo]
)
puts "#{Color::GREEN}✅ Nodo creado exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}ID:#{Color::RESET} #{nodo['id']}"
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{nodo['nombre']}"
puts " #{Color::BOLD}IP:#{Color::RESET} #{nodo['ip'] || '(sin IP)'}"
puts " #{Color::BOLD}Tipo:#{Color::RESET} #{nodo['tipo']}"
puts " #{Color::BOLD}Activo:#{Color::RESET} #{nodo['activo'] ? 'Sí' : 'No'}"
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{nodo['descripcion'] || '(sin descripción)'}"
puts " #{Color::BOLD}Creado:#{Color::RESET} #{nodo['created_at']}"
rescue PG::UniqueViolation
puts "#{Color::YELLOW}⚠ Ya existe un nodo con el nombre '#{opts[:nombre]}'#{Color::RESET}"
puts " Usa #{Color::DIM}adn db nodo:listar#{Color::RESET} para ver nodos existentes"
rescue => e
puts "#{Color::RED}✗ Error creando nodo: #{e.message}#{Color::RESET}"
end
end
end
# Comando: nodo:listar [OPCIONES] - Listar nodos del sistema
def comando_nodo_listar(args)
opts = { activo: nil, tipo: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db nodo:listar [OPCIONES]"
opt.on("--activo", "Mostrar solo nodos activos") do
opts[:activo] = true
end
opt.on("--inactivo", "Mostrar solo nodos inactivos") do
opts[:activo] = false
end
opt.on("--tipo TIPO", "Filtrar por tipo (servidor, hipervisor, vm, pc)") do |t|
opts[:tipo] = t
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}🖥️ Listando nodos...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
nodos = db.list_nodos(activo: opts[:activo], tipo: opts[:tipo], limit: 100)
if nodos.empty?
puts "#{Color::YELLOW}⚠ No se encontraron nodos#{Color::RESET}"
return
end
puts ""
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"IP".ljust(15)} #{"Tipo".ljust(12)} #{"Activo".ljust(6)} #{"Descripción".ljust(30)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 20} #{"" * 15} #{"" * 12} #{"" * 6} #{"" * 30}"
nodos.each do |n|
id = n['id'].to_s
nombre = n['nombre']
ip = n['ip'] || '(sin IP)'
tipo = n['tipo']
activo = n['activo'] ? '✅' : '❌'
desc = n['descripcion'] || ''
desc = desc[0..27] + '...' if desc.length > 30
puts "#{id.ljust(5)} #{nombre.ljust(20)} #{ip.ljust(15)} #{tipo.ljust(12)} #{activo.ljust(6)} #{desc.ljust(30)}"
end
puts ""
puts "Total: #{nodos.length} nodos"
# Estadísticas por tipo
tipos = nodos.group_by { |n| n['tipo'] }
puts "#{Color::DIM}Tipos: #{tipos.map { |t, ns| "#{t}: #{ns.length}" }.join(', ')}#{Color::RESET}"
end
end
# Comando: migrar:md [FECHA] - Migrar bitácora .md a base de datos
def comando_migrar_md(args)
opts = { sobrescribir: false, crear_nodos: true, todo: false }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db migrar:md [FECHA] [OPCIONES]"
opt.on("--sobrescribir", "Sobrescribir bitácora si ya existe") do
opts[:sobrescribir] = true
end
opt.on("--no-crear-nodos", "No crear nodos automáticamente") do
opts[:crear_nodos] = false
end
opt.on("--todo", "Importar todas las bitácoras del directorio") do
opts[:todo] = true
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
# Si se especificó --todo, importar todas las bitácoras
if opts[:todo]
importar_todas_bitacoras(opts)
return
end
# Si no quedan argumentos, mostrar ayuda
if args.empty?
puts "#{Color::YELLOW}⚠ Se requiere fecha o --todo#{Color::RESET}"
puts parser
exit 1
end
fecha_arg = args[0]
begin
fecha = Date.parse(fecha_arg)
rescue Date::Error
puts "#{Color::RED}✗ Formato de fecha inválido: #{fecha_arg}#{Color::RESET}"
puts " Usa formato YYYY-MM-DD (ej: 2026-03-07)"
exit 1
end
archivo_md = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
unless File.exist?(archivo_md)
puts "#{Color::RED}✗ Archivo de bitácora no encontrado: #{archivo_md}#{Color::RESET}"
puts " Verifica que la bitácora .md existe en #{ADN::BITACORAS_DIR}/"
exit 1
end
puts "#{Color::CYAN}🔄 Migrando bitácora #{fecha} de .md a base de datos...#{Color::RESET}"
puts " #{Color::DIM}Opciones: sobrescribir=#{opts[:sobrescribir]}, crear_nodos=#{opts[:crear_nodos]}#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
begin
# Crear instancia del importador
importer = BitacorasDB::Importers::MDImporter.new(db, @logger)
# Importar bitácora
resultado = importer.importar_bitacora(
archivo_md,
sobrescribir: opts[:sobrescribir],
crear_nodos: opts[:crear_nodos]
)
mostrar_resultado_migracion(resultado, fecha)
rescue => e
puts "#{Color::RED}✗ Error durante migración: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
end
end
end
# Comando: estadisticas - Mostrar estadísticas del sistema
def comando_estadisticas
puts "#{Color::CYAN}📊 Estadísticas del sistema dtic-BITACORAs#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
stats = db.statistics
puts ""
puts "#{Color::BOLD}📈 Conteos de tablas:#{Color::RESET}"
puts " #{Color::DIM}Nodos:#{Color::RESET} #{stats[:nodos] || 0}"
puts " #{Color::DIM}Bitácoras:#{Color::RESET} #{stats[:bitacoras] || 0}"
puts " #{Color::DIM}Temas:#{Color::RESET} #{stats[:temas] || 0}"
puts " #{Color::DIM}Entradas:#{Color::RESET} #{stats[:entradas] || 0}"
if stats[:nodos_con_entradas]
puts ""
puts "#{Color::BOLD}📊 Estadísticas de actividad:#{Color::RESET}"
puts " #{Color::DIM}Nodos con entradas:#{Color::RESET} #{stats[:nodos_con_entradas]}"
puts " #{Color::DIM}Días con actividad:#{Color::RESET} #{stats[:dias_con_actividad]}"
puts " #{Color::DIM}Entradas generadas por IA:#{Color::RESET} #{stats[:entradas_ia]}"
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{stats[:entradas_completadas]}"
end
# Calcular porcentajes
total_entradas = stats[:entradas] || 0
if total_entradas > 0
porcentaje_ia = (stats[:entradas_ia] || 0) * 100 / total_entradas
porcentaje_completadas = (stats[:entradas_completadas] || 0) * 100 / total_entradas
puts ""
puts "#{Color::BOLD}📈 Porcentajes:#{Color::RESET}"
puts " #{Color::DIM}Entradas IA:#{Color::RESET} #{porcentaje_ia}%"
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{porcentaje_completadas}%"
end
end
def comando_backup(args)
opts = { output: nil, compress: true, timestamp: true }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db backup [OPCIONES]"
opt.on("--output FILE", "Archivo de salida (default: backup_YYYYMMDD_HHMMSS.sql)") do |file|
opts[:output] = file
end
opt.on("--no-compress", "No comprimir el archivo de backup") do
opts[:compress] = false
end
opt.on("--no-timestamp", "No agregar timestamp al nombre del archivo") do
opts[:timestamp] = false
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}💾 Creando backup de la base de datos dtic-BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Compresión: #{opts[:compress]}, Timestamp: #{opts[:timestamp]}#{Color::RESET}"
# Cargar configuración de base de datos
config_path = File.join(__dir__, '..', '..', 'config', 'database.yml')
unless File.exist?(config_path)
puts "#{Color::RED}✗ Archivo de configuración no encontrado: #{config_path}#{Color::RESET}"
exit 1
end
begin
config = YAML.load_file(config_path)
db_config = config['development'] || config.first[1] # Tomar primer entorno si no hay development
host = db_config['host'] || 'localhost'
port = db_config['port'] || 5433
database = db_config['database']
username = db_config['username']
password = db_config['password']
unless database && username
puts "#{Color::RED}✗ Configuración de base de datos incompleta#{Color::RESET}"
exit 1
end
# Generar nombre de archivo
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
base_name = "dtic_bitacoras_backup_#{timestamp}"
sql_file = opts[:output] || "#{base_name}.sql"
final_file = opts[:compress] ? "#{sql_file}.gz" : sql_file
puts " #{Color::DIM}Archivo de salida: #{final_file}#{Color::RESET}"
# Construir comando pg_dump
dump_cmd = "PGPASSWORD=\"#{password}\" pg_dump"
dump_cmd += " -h #{host} -p #{port} -U #{username} -d #{database}"
dump_cmd += " --schema=bitacoras" # Solo esquema bitacoras
dump_cmd += " --clean --if-exists" # Incluir DROP statements
dump_cmd += " --no-owner --no-privileges" # No incluir permisos
dump_cmd += " --inserts" # Usar INSERT en lugar de COPY
if opts[:compress]
dump_cmd += " | gzip -c > \"#{final_file}\""
else
dump_cmd += " > \"#{final_file}\""
end
# Ejecutar backup
puts " #{Color::DIM}Ejecutando pg_dump...#{Color::RESET}"
system(dump_cmd)
if $?.success?
file_size = File.size(final_file) rescue 0
size_str = file_size > 0 ? " (#{file_size / 1024} KB)" : ""
puts "#{Color::GREEN}✅ Backup creado exitosamente: #{final_file}#{size_str}#{Color::RESET}"
puts " #{Color::DIM}Para restaurar:#{Color::RESET} gunzip -c #{final_file} | psql -h #{host} -p #{port} -U #{username} -d #{database}"
else
puts "#{Color::RED}✗ Error creando backup#{Color::RESET}"
# Limpiar archivo parcial si existe
File.delete(final_file) if File.exist?(final_file)
exit 1
end
rescue => e
puts "#{Color::RED}✗ Error durante backup: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
end
end
# Importa todas las bitácoras del directorio bitacoras/
def importar_todas_bitacoras(opciones)
puts "#{Color::CYAN}🔄 Migrando todas las bitácoras .md a base de datos...#{Color::RESET}"
puts " #{Color::DIM}Opciones: sobrescribir=#{opciones[:sobrescribir]}, crear_nodos=#{opciones[:crear_nodos]}#{Color::RESET}"
puts ""
BitacorasDB::BitacoraDB.with_connection do |db|
# Crear instancia del importador
importer = BitacorasDB::Importers::MDImporter.new(db, @logger)
# Importar directorio completo
resultado = importer.importar_directorio(
ADN::BITACORAS_DIR,
sobrescribir: opciones[:sobrescribir],
crear_nodos: opciones[:crear_nodos]
)
# Mostrar resultados consolidados
totales = resultado[:totales]
estadisticas = resultado[:estadisticas]
puts ""
puts "#{Color::BOLD}📊 Resultado de migración masiva:#{Color::RESET}"
puts " #{Color::DIM}Archivos procesados:#{Color::RESET} #{totales[:archivos]}"
puts " #{Color::DIM}Migraciones exitosas:#{Color::RESET} #{totales[:exitosos]}"
puts " #{Color::DIM}Omitidos (ya existían):#{Color::RESET} #{totales[:omitidos]}"
puts " #{Color::DIM}Errores:#{Color::RESET} #{totales[:errores]}"
if estadisticas[:errores].any?
puts ""
puts "#{Color::YELLOW}⚠ Errores encontrados:#{Color::RESET}"
estadisticas[:errores].each_with_index do |error, idx|
puts " #{idx + 1}. #{error}"
end
end
puts ""
puts "#{Color::BOLD}📈 Estadísticas acumuladas:#{Color::RESET}"
puts " #{Color::DIM}Bitácoras creadas:#{Color::RESET} #{estadisticas[:bitacoras_creadas]}"
puts " #{Color::DIM}Nodos creados:#{Color::RESET} #{estadisticas[:nodos_creados]}"
puts " #{Color::DIM}Temas creados:#{Color::RESET} #{estadisticas[:temas_creados]}"
puts " #{Color::DIM}Entradas creadas:#{Color::RESET} #{estadisticas[:entradas_creadas]}"
if totales[:errores] > 0
puts ""
puts "#{Color::YELLOW}⚠ Algunas bitácoras no se migraron correctamente#{Color::RESET}"
puts " Revisa los errores arriba y corrige los problemas"
elsif totales[:exitosos] > 0
puts ""
puts "#{Color::GREEN}✅ Migración masiva completada exitosamente#{Color::RESET}"
end
end
rescue => e
puts "#{Color::RED}✗ Error durante migración masiva: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
end
# Muestra el resultado de una migración individual
def mostrar_resultado_migracion(resultado, fecha)
if resultado[:exitoso]
puts "#{Color::GREEN}✅ Bitácora migrada exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{fecha}"
puts " #{Color::BOLD}Bitácora ID:#{Color::RESET} #{resultado[:bitacora_id]}" if resultado[:bitacora_id]
puts " #{Color::BOLD}Estadísticas:#{Color::RESET}"
puts " • Nodos creados: #{resultado.dig(:estadisticas, :nodos_creados) || 0}"
puts " • Temas creados: #{resultado.dig(:estadisticas, :temas_creados) || 0}"
puts " • Entradas creadas: #{resultado.dig(:estadisticas, :entradas_creadas) || 0}"
elsif resultado[:skip]
puts "#{Color::YELLOW}⚠ Bitácora ya existe, omitiendo#{Color::RESET}"
puts " Usa --sobrescribir para forzar migración"
puts " Bitácora ID: #{resultado[:bitacora_id]}" if resultado[:bitacora_id]
else
puts "#{Color::RED}✗ Error migrando bitácora#{Color::RESET}"
if resultado[:error]
puts " #{resultado[:error]}"
end
if resultado[:errores]&.any?
resultado[:errores].each do |error|
puts "#{error}"
end
end
end
end
# ─── MÉTODOS AUXILIARES ──────────────────────────────────────────────
# Carga la configuración desde config.yml
def cargar_configuracion
config_path = File.join(__dir__, 'config.yml')
return {} unless File.exist?(config_path)
begin
YAML.load_file(config_path)
rescue => e
@logger.error("Error cargando configuración: #{e.message}")
{}
end
end
# Muestra la ayuda general del subcomando
def mostrar_ayuda
puts "#{Color::BOLD}Uso: ./tools/adn/run db [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
puts ""
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
puts ""
puts " #{Color::CYAN}salud:bd#{Color::RESET} Verificar conexión y estado de la BD"
puts " #{Color::CYAN}backup [OPCIONES]#{Color::RESET} Crear backup de la base de datos"
puts " #{Color::CYAN}bitacora:crear [FECHA]#{Color::RESET} Crear nueva bitácora diaria"
puts " #{Color::CYAN}bitacora:listar [OPCIONES]#{Color::RESET} Listar bitácoras existentes"
puts " #{Color::CYAN}evento:crear [OPCIONES]#{Color::RESET} Crear nueva entrada cronológica"
puts " #{Color::CYAN}evento:listar [OPCIONES]#{Color::RESET} Listar entradas con filtros"
puts " #{Color::CYAN}nodo:crear [OPCIONES]#{Color::RESET} Crear nuevo nodo (servidor/VM/PC)"
puts " #{Color::CYAN}nodo:listar [OPCIONES]#{Color::RESET} Listar nodos del sistema"
puts " #{Color::CYAN}migrar:md [FECHA]#{Color::RESET} Migrar bitácora .md a base de datos"
puts " #{Color::CYAN}estadisticas#{Color::RESET} Mostrar estadísticas del sistema"
puts ""
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
puts " ./tools/adn/run db salud:bd"
puts " ./tools/adn/run db bitacora:crear --resumen \"Inicio de proyecto\""
puts " ./tools/adn/run db evento:crear --inicio 09:00 --descripcion \"Reunión\" --nodo srv-ns8"
puts " ./tools/adn/run db nodo:listar --tipo servidor"
puts ""
puts "#{Color::YELLOW}⚠ Nota:#{Color::RESET} Requiere PostgreSQL en ejecución (puerto 5433)"
puts " Iniciar contenedor: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
end
end
end
+517
View File
@@ -0,0 +1,517 @@
# frozen_string_literal: true
# tools/adn/subcomando_plan.rb — Subcomando para seguimiento y gestión del plan ADN
# ==============================================================================
# Este subcomando proporciona herramientas para el seguimiento, actualización y
# reporte del plan integral de integración ADN con dtic-BITACORAs.
#
# Características principales:
# • Parseo y análisis del archivo de plan Markdown
# • Actualización del estado de tareas (pendiente/completado)
# • Generación de reportes de progreso en múltiples formatos
# • Validación de integridad del plan
# • Exportación de datos de progreso
#
# Comandos disponibles:
# • adn plan progreso [OPCIONES] — Mostrar progreso actual del plan
# • adn plan actualizar [TAREAS] — Marcar tareas como completadas
# • adn plan reporte [--formato] — Generar reporte detallado
# • adn plan validar — Validar integridad del plan
# • adn plan exportar [--output] — Exportar datos de progreso
# • adn plan ayuda — Mostrar ayuda completa
#
# Uso:
# ./tools/adn/run plan [SUBCOMANDO] [OPCIONES]
require 'optparse'
require 'date'
require 'time'
require 'json'
require 'yaml'
# Cargar módulo PlanParser
begin
require_relative '../plan/parser'
rescue LoadError => e
puts "⚠ Advertencia: No se pudo cargar PlanParser: #{e.message}"
puts " Asegúrate de que tools/adn/plan/parser.rb existe"
end
module ADN
class SubcomandoPlan
# Colores ANSI para terminal (consistentes con adn.rb)
module Color
RESET = "\e[0m"
BOLD = "\e[1m"
GREEN = "\e[32m"
CYAN = "\e[36m"
YELLOW = "\e[33m"
RED = "\e[31m"
MAGENTA = "\e[35m"
DIM = "\e[2m"
end
# Ruta por defecto del archivo de plan
DEFAULT_PLAN_PATH = File.expand_path('../../docs/plan/260308-1200_Plan_Integral_ADN_BITACORAs.md', __dir__)
# Inicializa el subcomando con argumentos y logger
#
# @param args [Array<String>] Argumentos de línea de comandos
# @param logger [ADNLogger] Instancia de logger para registro
def initialize(args, logger)
@args = args
@logger = logger
@plan_path = DEFAULT_PLAN_PATH
end
# Ejecuta el subcomando según los argumentos proporcionados
def ejecutar
if @args.empty? || @args[0] == 'ayuda' || @args[0] == 'help' || @args[0] == '-h' || @args[0] == '--help'
mostrar_ayuda
return
end
subcomando = @args[0]
sub_args = @args[1..-1] || []
case subcomando
when 'progreso'
comando_progreso(sub_args)
when 'actualizar'
comando_actualizar(sub_args)
when 'reporte'
comando_reporte(sub_args)
when 'validar'
comando_validar(sub_args)
when 'exportar'
comando_exportar(sub_args)
else
@logger.error("Subcomando desconocido: #{subcomando}")
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
puts ""
mostrar_ayuda
exit 1
end
rescue Interrupt
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
@logger.info("Operación de plan interrumpida por usuario")
exit 130
rescue => e
@logger.error("Error en operación de plan: #{e.message}", { backtrace: e.backtrace })
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
private
# Comando: progreso - Mostrar progreso actual del plan
def comando_progreso(args)
opts = { detallado: false, formato: 'texto' }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan progreso [OPCIONES]"
opt.on("--detallado", "Mostrar progreso detallado por fase y semana") do
opts[:detallado] = true
end
opt.on("--formato FORMATO", "Formato de salida (texto, json, markdown)") do |f|
opts[:formato] = f.downcase
end
opt.on("--plan RUTA", "Ruta al archivo de plan (default: #{File.basename(@plan_path)})") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📊 Analizando progreso del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
if opts[:formato] == 'json'
puts parser_obj.progress_report(format: :json)
elsif opts[:formato] == 'markdown'
puts parser_obj.progress_report(format: :markdown)
else
# Formato texto (default)
puts parser_obj.progress_report(format: :text)
if opts[:detallado]
puts ""
puts "#{Color::BOLD}📈 DETALLE POR FASES Y SEMANAS:#{Color::RESET}"
stats = parser_obj.progress_stats
stats[:phases].each do |phase|
puts ""
puts " #{Color::CYAN}FASE #{phase[:number]}: #{phase[:name]}#{Color::RESET}"
puts " Progreso: #{phase[:percentage]}% (#{phase[:completed]}/#{phase[:total]} tareas)"
# Mostrar tareas de esta fase
phase_tasks = parser_obj.tasks_by_phase(phase[:number])
completed_tasks = phase_tasks.select { |t| t[:completed] }
pending_tasks = phase_tasks.reject { |t| t[:completed] }
if completed_tasks.any?
puts " #{Color::GREEN}✓ Completadas:#{Color::RESET}"
completed_tasks.first(3).each do |task|
puts "#{task[:id]}: #{task[:description]}"
end
if completed_tasks.size > 3
puts " ... y #{completed_tasks.size - 3} más"
end
end
if pending_tasks.any?
puts " #{Color::YELLOW}⏳ Pendientes:#{Color::RESET}"
pending_tasks.first(3).each do |task|
puts "#{task[:id]}: #{task[:description]}"
end
if pending_tasks.size > 3
puts " ... y #{pending_tasks.size - 3} más"
end
end
end
end
end
rescue => e
puts "#{Color::RED}✗ Error analizando el plan: #{e.message}#{Color::RESET}"
puts " #{Color::DIM}Asegúrate de que el archivo existe y tiene el formato correcto#{Color::RESET}"
exit 1
end
end
# Comando: actualizar - Marcar tareas como completadas
def comando_actualizar(args)
if args.empty?
puts "#{Color::YELLOW}⚠ Se requiere al menos una tarea para actualizar#{Color::RESET}"
puts " Uso: adn plan actualizar TAREA1 [TAREA2 ...] [OPCIONES]"
exit 1
end
opts = { notas: nil, guardar: true }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan actualizar TAREA1 [TAREA2 ...] [OPCIONES]"
opt.on("--notas TEXTO", "Agregar notas a las tareas actualizadas") do |texto|
opts[:notas] = texto
end
opt.on("--no-guardar", "No guardar cambios en el archivo (solo simular)") do
opts[:guardar] = false
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
# Separar tareas de opciones
task_args = []
begin
parser.parse!(args)
task_args = args # Los argumentos restantes son las tareas
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
if task_args.empty?
puts "#{Color::YELLOW}⚠ No se especificaron tareas para actualizar#{Color::RESET}"
puts " Ejemplo: adn plan actualizar 1.1 1.2 2.3"
exit 1
end
puts "#{Color::CYAN}🔄 Actualizando tareas del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Tareas: #{task_args.join(', ')}#{Color::RESET}"
puts " #{Color::DIM}Notas: #{opts[:notas] || '(ninguna)'}#{Color::RESET}"
puts " #{Color::DIM}Guardar: #{opts[:guardar]}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
resultados = parser_obj.complete_tasks(task_args, opts[:notas])
if resultados[:updated].any?
puts "#{Color::GREEN}✅ Tareas actualizadas exitosamente:#{Color::RESET}"
resultados[:updated].each do |task_id|
task = parser_obj.find_task_by_id(task_id)
puts "#{task_id}: #{task[:description]}"
end
if opts[:guardar]
if parser_obj.save_changes
puts ""
puts "#{Color::GREEN}✓ Cambios guardados en #{@plan_path}#{Color::RESET}"
else
puts ""
puts "#{Color::YELLOW}⚠ No se realizaron cambios o no se pudieron guardar#{Color::RESET}"
end
else
puts ""
puts "#{Color::YELLOW}⚠ Modo simulación - cambios no guardados#{Color::RESET}"
puts " Usa --guardar para persistir los cambios"
end
end
if resultados[:failed].any?
puts ""
puts "#{Color::YELLOW}⚠ Tareas no encontradas:#{Color::RESET}"
resultados[:failed].each do |task_id|
puts "#{task_id}"
end
end
# Mostrar progreso actualizado
if resultados[:updated].any?
puts ""
stats = parser_obj.progress_stats
puts " #{Color::BOLD}Progreso actualizado: #{stats[:overall_percentage]}%#{Color::RESET}"
puts " (#{stats[:completed_tasks]}/#{stats[:total_tasks]} tareas completadas)"
end
rescue => e
puts "#{Color::RED}✗ Error actualizando tareas: #{e.message}#{Color::RESET}"
exit 1
end
end
# Comando: reporte - Generar reporte detallado
def comando_reporte(args)
opts = { formato: 'texto', output: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan reporte [OPCIONES]"
opt.on("--formato FORMATO", "Formato del reporte (texto, json, markdown)") do |f|
opts[:formato] = f.downcase
end
opt.on("--output ARCHIVO", "Guardar reporte en archivo (en lugar de mostrar)") do |archivo|
opts[:output] = archivo
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📋 Generando reporte del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Formato: #{opts[:formato]}#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
reporte = parser_obj.progress_report(format: opts[:formato].to_sym)
if opts[:output]
File.write(opts[:output], reporte, encoding: 'UTF-8')
puts "#{Color::GREEN}✅ Reporte guardado en: #{opts[:output]}#{Color::RESET}"
puts " #{Color::DIM}Tamaño: #{reporte.bytesize} bytes#{Color::RESET}"
else
puts reporte
end
rescue => e
puts "#{Color::RED}✗ Error generando reporte: #{e.message}#{Color::RESET}"
exit 1
end
end
# Comando: validar - Validar integridad del plan
def comando_validar(args)
opts = { reparar: false }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan validar [OPCIONES]"
opt.on("--reparar", "Intentar reparar problemas automáticamente") do
opts[:reparar] = true
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}🔍 Validando integridad del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts " #{Color::DIM}Reparar: #{opts[:reparar]}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
resultados = parser_obj.validate_plan
if resultados[:valid]
puts "#{Color::GREEN}✅ Plan válido#{Color::RESET}"
puts "#{resultados[:tasks_count]} tareas detectadas"
puts "#{resultados[:phases_count]} fases detectadas"
else
puts "#{Color::RED}✗ Plan contiene errores:#{Color::RESET}"
resultados[:errors].each do |error|
puts "#{error}"
end
end
if resultados[:warnings].any?
puts ""
puts "#{Color::YELLOW}⚠ Advertencias encontradas:#{Color::RESET}"
resultados[:warnings].each do |warning|
puts "#{warning}"
end
end
if opts[:reparar] && (!resultados[:valid] || resultados[:warnings].any?)
puts ""
puts "#{Color::CYAN}🔄 Intentando reparar automáticamente...#{Color::RESET}"
# Aquí se podrían implementar reparaciones automáticas
puts " #{Color::DIM}(Reparación automática no implementada aún)#{Color::RESET}"
end
rescue => e
puts "#{Color::RED}✗ Error validando el plan: #{e.message}#{Color::RESET}"
exit 1
end
end
# Comando: exportar - Exportar datos de progreso
def comando_exportar(args)
opts = { formato: 'json', output: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan exportar [OPCIONES]"
opt.on("--formato FORMATO", "Formato de exportación (json, yaml)") do |f|
opts[:formato] = f.downcase
end
opt.on("--output ARCHIVO", "Archivo de salida (default: progreso_YYYYMMDD.json)") do |archivo|
opts[:output] = archivo
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}💾 Exportando datos de progreso del plan...#{Color::RESET}"
puts " #{Color::DIM}Formato: #{opts[:formato]}#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
if opts[:formato] == 'yaml'
datos = {
metadata: parser_obj.instance_variable_get(:@metadata),
progress: parser_obj.progress_stats,
tasks: parser_obj.instance_variable_get(:@tasks).map { |t| t.reject { |k| k == :line_number } },
phases: parser_obj.instance_variable_get(:@phases)
}
contenido = YAML.dump(datos)
extension = '.yaml'
else
contenido = parser_obj.export_progress
extension = '.json'
end
output_file = opts[:output] || "progreso_#{Time.now.strftime('%Y%m%d')}#{extension}"
File.write(output_file, contenido, encoding: 'UTF-8')
puts "#{Color::GREEN}✅ Datos exportados exitosamente:#{Color::RESET}"
puts " • Archivo: #{output_file}"
puts " • Tamaño: #{contenido.bytesize} bytes"
puts " • Tareas: #{parser_obj.instance_variable_get(:@tasks).size}"
puts " • Fases: #{parser_obj.instance_variable_get(:@phases).size}"
rescue => e
puts "#{Color::RED}✗ Error exportando datos: #{e.message}#{Color::RESET}"
exit 1
end
end
# Muestra la ayuda general del subcomando
def mostrar_ayuda
puts "#{Color::BOLD}Uso: ./tools/adn/run plan [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
puts ""
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
puts ""
puts " #{Color::CYAN}progreso [OPCIONES]#{Color::RESET} Mostrar progreso actual del plan"
puts " #{Color::CYAN}actualizar TAREAS [OPCIONES]#{Color::RESET} Marcar tareas como completadas"
puts " #{Color::CYAN}reporte [OPCIONES]#{Color::RESET} Generar reporte detallado"
puts " #{Color::CYAN}validar [OPCIONES]#{Color::RESET} Validar integridad del plan"
puts " #{Color::CYAN}exportar [OPCIONES]#{Color::RESET} Exportar datos de progreso"
puts " #{Color::CYAN}ayuda#{Color::RESET} Mostrar esta ayuda"
puts ""
puts "#{Color::BOLD}Opciones comunes:#{Color::RESET}"
puts " #{Color::DIM}--plan RUTA#{Color::RESET} Ruta al archivo de plan"
puts " #{Color::DIM}--formato FORMATO#{Color::RESET} Formato de salida (texto, json, markdown, yaml)"
puts " #{Color::DIM}--output ARCHIVO#{Color::RESET} Guardar salida en archivo"
puts ""
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
puts " #{Color::DIM}./tools/adn/run plan progreso --detallado#{Color::RESET}"
puts " #{Color::DIM}./tools/adn/run plan actualizar 1.1 1.2 2.3 --notas \"Implementado\"#{Color::RESET}"
puts " #{Color::DIM}./tools/adn/run plan reporte --formato markdown --output reporte.md#{Color::RESET}"
puts " #{Color::DIM}./tools/adn/run plan validar --reparar#{Color::RESET}"
puts " #{Color::DIM}./tools/adn/run plan exportar --formato json --output progreso.json#{Color::RESET}"
puts ""
puts "#{Color::YELLOW}⚠ Archivo de plan por defecto:#{Color::RESET} #{File.basename(DEFAULT_PLAN_PATH)}"
puts " #{Color::DIM}#{DEFAULT_PLAN_PATH}#{Color::RESET}"
end
end
end
+282
View File
@@ -0,0 +1,282 @@
# frozen_string_literal: true
# tools/adn/subcomando_sync.rb — Subcomando para sincronizar archivos MD con API dtic-BITACORAs
# =============================================================================================
# Este subcomando permite sincronizar bitácoras en formato Markdown con la base de datos
# de dtic-BITACORAs mediante la API REST.
require_relative '../clientes/dtic_bitacoras_api'
require 'optparse'
require 'fileutils'
module ADN
class SubcomandoSync
def initialize(args, logger)
@args = args
@logger = logger
@opciones = {
sobrescribir: false,
validar_primero: true,
batch: false,
fecha: nil,
ruta: nil
}
@resultados = {
archivos_procesados: 0,
archivos_exitosos: 0,
archivos_fallidos: 0,
entradas_sincronizadas: 0,
errores: []
}
end
def ejecutar
parser = OptionParser.new do |opts|
opts.banner = "Uso: ./tools/adn/run sync [OPCIONES] [RUTA_O_FECHA]"
opts.separator ""
opts.separator "Sincroniza archivos Markdown con la API de dtic-BITACORAs"
opts.separator ""
opts.separator "Opciones:"
opts.on("--sobrescribir", "Sobrescribir entradas existentes en la base de datos") do
@opciones[:sobrescribir] = true
end
opts.on("--no-validar", "No validar archivos antes de sincronizar") do
@opciones[:validar_primero] = false
end
opts.on("--batch", "Sincronizar todos los archivos en el directorio bitacoras/") do
@opciones[:batch] = true
end
opts.on("--fecha FECHA", "Sincronizar bitácora específica (formato: YYYY-MM-DD)") do |f|
@opciones[:fecha] = f
end
opts.on("-h", "--help", "Mostrar esta ayuda") do
puts opts
exit
end
end
begin
parser.parse!(@args)
rescue OptionParser::InvalidOption => e
@logger.error("Opción inválida: #{e.message}")
puts parser
exit 1
end
# Determinar qué sincronizar
if @args.any? && !@opciones[:batch] && !@opciones[:fecha]
@opciones[:ruta] = @args[0]
end
ejecutar_sincronizacion
end
private
def ejecutar_sincronizacion
@logger.info("Iniciando sincronización con API dtic-BITACORAs", @opciones)
# Inicializar cliente API
begin
@client = DticBitacorasApi::Client.new(logger: @logger)
salud = @client.salud
@logger.exito("API dtic-BITACORAs disponible: #{salud['status']}")
rescue DticBitacorasApi::ApiError => e
@logger.error("No se puede conectar a la API: #{e.message}")
mostrar_conexion_fallback
exit 1
rescue DticBitacorasApi::ConfigError => e
@logger.error("Error de configuración: #{e.message}")
puts "Configura la URL de la API en tools/adn/config.yml o con export DTIC_API_URL=http://localhost:3002"
exit 1
end
# Determinar archivos a sincronizar
archivos = determinar_archivos_a_sincronizar
if archivos.empty?
@logger.advertencia("No se encontraron archivos para sincronizar")
mostrar_estadisticas
exit 0
end
puts "\n#{Color::CYAN}🔄 Sincronizando #{archivos.size} archivo(s) con dtic-BITACORAs#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
# Procesar cada archivo
archivos.each_with_index do |archivo, index|
procesar_archivo(archivo, index + 1, archivos.size)
end
mostrar_estadisticas
end
def determinar_archivos_a_sincronizar
archivos = []
if @opciones[:batch]
# Sincronizar todos los archivos en bitacoras/
patron = File.join(ADN::BITACORAS_DIR, "*.md")
archivos = Dir.glob(patron).sort
elsif @opciones[:fecha]
# Sincronizar archivo específico por fecha
ruta = File.join(ADN::BITACORAS_DIR, "#{@opciones[:fecha]}.md")
archivos = File.exist?(ruta) ? [ruta] : []
elsif @opciones[:ruta]
# Sincronizar archivo específico por ruta
archivos = [File.expand_path(@opciones[:ruta])]
else
# Sincronizar archivo del día actual
fecha_hoy = Date.today.strftime('%Y-%m-%d')
ruta_hoy = File.join(ADN::BITACORAS_DIR, "#{fecha_hoy}.md")
archivos = File.exist?(ruta_hoy) ? [ruta_hoy] : []
end
archivos
end
def procesar_archivo(ruta_archivo, indice, total)
nombre = File.basename(ruta_archivo)
puts "\n#{Color::CYAN}[#{indice}/#{total}] #{nombre}#{Color::RESET}"
unless File.exist?(ruta_archivo)
@resultados[:errores] << "#{nombre}: Archivo no encontrado"
@resultados[:archivos_fallidos] += 1
puts " #{Color::RED}✗ Archivo no encontrado#{Color::RESET}"
return
end
# Extraer fecha del nombre del archivo
fecha_match = nombre.match(/(\d{4}-\d{2}-\d{2})\.md/)
unless fecha_match
@resultados[:errores] << "#{nombre}: Formato de nombre inválido (debe ser YYYY-MM-DD.md)"
@resultados[:archivos_fallidos] += 1
puts " #{Color::RED}✗ Formato de nombre inválido#{Color::RESET}"
return
end
fecha = fecha_match[1]
# Validar formato del archivo si está habilitado
if @opciones[:validar_primero]
unless validar_archivo(ruta_archivo)
@resultados[:errores] << "#{nombre}: Falló validación previa"
@resultados[:archivos_fallidos] += 1
return
end
end
# Sincronizar con la API
begin
resultado = @client.sincronizar_md(ruta_archivo, sobrescribir: @opciones[:sobrescribir])
@resultados[:archivos_procesados] += 1
@resultados[:archivos_exitosos] += 1
@resultados[:entradas_sincronizadas] += resultado[:entradas_procesadas]
puts " #{Color::GREEN}✓ Sincronizado exitosamente#{Color::RESET}"
puts " #{Color::DIM}Bitácora ID: #{resultado[:bitacora_id]}#{Color::RESET}"
puts " #{Color::DIM}Entradas: #{resultado[:entradas_procesadas]}#{Color::RESET}"
@logger.info("Archivo sincronizado: #{nombre}", resultado)
rescue DticBitacorasApi::ApiError => e
@resultados[:archivos_procesados] += 1
@resultados[:archivos_fallidos] += 1
@resultados[:errores] << "#{nombre}: #{e.message}"
puts " #{Color::RED}✗ Error de API: #{e.message}#{Color::RESET}"
@logger.error("Error sincronizando #{nombre}: #{e.message}")
rescue => e
@resultados[:archivos_procesados] += 1
@resultados[:archivos_fallidos] += 1
@resultados[:errores] << "#{nombre}: Error inesperado - #{e.message}"
puts " #{Color::RED}✗ Error inesperado: #{e.message}#{Color::RESET}"
@logger.error("Error inesperado en #{nombre}: #{e.message}", backtrace: e.backtrace)
end
end
def validar_archivo(ruta_archivo)
puts " #{Color::CYAN}🔍 Validando formato...#{Color::RESET}"
contenido = File.read(ruta_archivo, encoding: 'UTF-8')
errores_validacion = []
# Validar formato básico
unless contenido.include?('## 📝 Actividades Detalladas')
errores_validacion << "Falta sección '## 📝 Actividades Detalladas'"
end
# Validar que tenga al menos una tabla de entradas
unless contenido.match(/\|\s*[I]\s*\|\s*[F]\s*\|\s*Descripción\s*\|\s*[E]\s*\|/)
errores_validacion << "No se encontró tabla de entradas I-F-D-E"
end
# Validar formato de tiempo
if contenido.match(/\d{1,2}:\d{2}\s*hs/)
errores_validacion << "Formato de tiempo incorrecto (contiene 'hs')"
end
if errores_validacion.empty?
puts " #{Color::GREEN}✓ Validación exitosa#{Color::RESET}"
true
else
puts " #{Color::YELLOW}⚠ Advertencias de validación:#{Color::RESET}"
errores_validacion.each { |e| puts " - #{e}" }
puts " #{Color::DIM}Continuando con sincronización...#{Color::RESET}"
true # Continuar a pesar de advertencias
end
end
def mostrar_estadisticas
puts "\n#{Color::CYAN}📊 Resultados de sincronización#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
puts " #{Color::GREEN}Archivos procesados:#{Color::RESET} #{@resultados[:archivos_procesados]}"
puts " #{Color::GREEN}Archivos exitosos:#{Color::RESET} #{@resultados[:archivos_exitosos]}"
if @resultados[:archivos_fallidos] > 0
puts " #{Color::RED}Archivos fallidos:#{Color::RESET} #{@resultados[:archivos_fallidos]}"
else
puts " #{Color::GREEN}Archivos fallidos:#{Color::RESET} 0"
end
puts " #{Color::GREEN}Entradas sincronizadas:#{Color::RESET} #{@resultados[:entradas_sincronizadas]}"
# Mostrar estadísticas de la API
mostrar_estadisticas_api
# Mostrar errores si los hay
unless @resultados[:errores].empty?
puts "\n#{Color::YELLOW}⚠ Errores encontrados:#{Color::RESET}"
@resultados[:errores].each do |error|
puts " #{Color::RED}#{error}#{Color::RESET}"
end
end
# Resumen final
if @resultados[:archivos_fallidos] == 0
@logger.exito("Sincronización completada exitosamente")
puts "\n#{Color::GREEN}✅ Sincronización completada exitosamente#{Color::RESET}"
else
@logger.advertencia("Sincronización completada con errores")
puts "\n#{Color::YELLOW}⚠ Sincronización completada con #{@resultados[:archivos_fallidos]} error(es)#{Color::RESET}"
end
@logger.info("Resumen de sincronización", @resultados)
end
def mostrar_estadisticas_api
begin
stats = @client.estadisticas
puts "\n#{Color::CYAN}📈 Estadísticas de dtic-BITACORAs#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 40}#{Color::RESET}"
puts " #{Color::GREEN}Nodos en sistema:#{Color::RESET} #{stats[:nodos] || 0}"
puts " #{Color::GREEN}Bitácoras en sistema:#{Color::RESET} #{stats[:bitacoras] || 0}"
rescue => e
puts " #{Color::RED}No se pudieron obtener estadísticas: #{e.message}#{Color::RESET}"
end
end
end
end
+304
View File
@@ -0,0 +1,304 @@
# frozen_string_literal: true
# tools/adn/subcomando_triggers.rb — Subcomando para gestión del sistema de triggers ADN
# ================================================================================
# Este subcomando permite gestionar el sistema de triggers que implementa
# el Principio de Armonía Integral mediante la propagación automática de cambios.
#
# Integrado con: ADN::Triggers (tools/adn/triggers.rb)
# Configuración: adn/triggers.yml
#
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
require 'optparse'
require_relative '../core/triggers'
require_relative '../core/eventos'
module ADN
class SubcomandoTriggers
def initialize(args, logger)
@args = args
@logger = logger
@opciones = {
test: false,
forzar: false,
listar: false,
registrar: false,
limpiar: false,
trigger_id: nil,
ayuda: false
}
end
# Ejecuta el subcomando con las opciones especificadas
def ejecutar
parser = crear_parser
begin
parser.parse!(@args)
rescue OptionParser::InvalidOption => e
@logger.error("Opción inválida: #{e.message}")
puts parser
exit 1
end
if @opciones[:ayuda] || @args.empty? && !@opciones[:listar] && !@opciones[:limpiar] && !@opciones[:registrar]
mostrar_ayuda(parser)
return
end
# Cargar configuración primero
cargar_configuracion
# Ejecutar acción según opciones
if @opciones[:listar]
listar_triggers
elsif @opciones[:registrar]
registrar_triggers
elsif @opciones[:limpiar]
limpiar_triggers
elsif @opciones[:test]
probar_trigger
else
ejecutar_triggers
end
end
private
# Crea el parser de opciones
def crear_parser
OptionParser.new do |opts|
opts.banner = "Uso: ./tools/adn/run triggers [OPCIONES]"
opts.separator ""
opts.separator "Gestiona el sistema de triggers para sincronización automática del ADN"
opts.separator ""
opts.separator "Opciones:"
opts.on("--test [TRIGGER_ID]", "Probar un trigger específico (modo simulación)") do |trigger_id|
@opciones[:test] = true
@opciones[:trigger_id] = trigger_id
end
opts.on("--forzar", "Ejecutar triggers incluso si sistema deshabilitado") do
@opciones[:forzar] = true
end
opts.on("--listar", "Listar todos los triggers configurados") do
@opciones[:listar] = true
end
opts.on("--registrar", "Registrar triggers en el bus de eventos") do
@opciones[:registrar] = true
end
opts.on("--limpiar", "Limpiar todos los triggers registrados") do
@opciones[:limpiar] = true
end
opts.on("-h", "--help", "Mostrar esta ayuda") do
@opciones[:ayuda] = true
end
end
end
# Muestra la ayuda del subcomando
def mostrar_ayuda(parser)
puts parser
puts ""
puts "Descripción:"
puts " El sistema de triggers implementa el Principio de Armonía Integral al propagar"
puts " automáticamente cambios entre diferentes componentes del sistema ADN."
puts ""
puts " Triggers definidos en: adn/triggers.yml"
puts " Bus de eventos: tools/adn/eventos.rb"
puts " Motor de triggers: tools/adn/triggers.rb"
puts ""
puts "Ejemplos:"
puts " ./tools/adn/run triggers --listar"
puts " ./tools/adn/run triggers --registrar"
puts " ./tools/adn/run triggers --test bitacoras_0"
puts " ./tools/adn/run triggers --forzar"
puts " ./tools/adn/run triggers --limpiar"
puts ""
puts "📚 Documentación: docs/plan/260307-1400_Plan_Mejoras_ADN.md"
end
# Carga la configuración de triggers
def cargar_configuracion
@logger.info("Cargando configuración de triggers...")
begin
@triggers = ADN::Triggers.instance
@triggers.cargar_configuracion
@logger.exito("Configuración de triggers cargada")
rescue => e
@logger.error("Error cargando configuración: #{e.message}")
exit 1
end
end
# Lista todos los triggers configurados
def listar_triggers
@logger.info("Listando triggers configurados...")
triggers = @triggers.lista_triggers
if triggers.empty?
puts "#{Color::YELLOW} No se encontraron triggers configurados#{Color::RESET}"
return
end
puts "#{Color::CYAN}📋 Triggers configurados (#{triggers.length} total)#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 80}#{Color::RESET}"
triggers.each do |trigger|
estado = trigger[:habilitado] ? "#{Color::GREEN}#{Color::RESET}" : "#{Color::DIM}#{Color::RESET}"
suscriptores = trigger[:suscriptores_registrados] > 0 ?
"#{Color::CYAN}(#{trigger[:suscriptores_registrados]} susc.)#{Color::RESET}" : ""
puts " #{estado} #{trigger[:id]} #{suscriptores}"
puts " #{Color::DIM}Evento:#{Color::RESET} #{trigger[:evento]}"
puts " #{Color::DIM}Acción:#{Color::RESET} #{trigger[:accion]}"
if trigger[:condicion] && !trigger[:condicion].empty?
puts " #{Color::DIM}Condición:#{Color::RESET} #{trigger[:condicion]}"
end
puts " #{Color::DIM}Descripción:#{Color::RESET} #{trigger[:descripcion]}"
puts ""
end
habilitados = triggers.count { |t| t[:habilitado] }
puts "#{Color::DIM}Triggers habilitados: #{habilitados}/#{triggers.length}#{Color::RESET}"
@logger.info("Listado completado: #{triggers.length} triggers encontrados")
end
# Registra triggers en el bus de eventos
def registrar_triggers
@logger.info("Registrando triggers en el bus de eventos...")
registrados = @triggers.registrar_triggers
if registrados > 0
@logger.exito("#{registrados} trigger(s) registrado(s) en el bus de eventos")
puts "#{Color::GREEN}#{registrados} trigger(s) registrado(s)#{Color::RESET}"
puts "#{Color::DIM}Los triggers responderán a eventos automáticamente#{Color::RESET}"
else
@logger.advertencia("No se registraron triggers (sistema deshabilitado o sin triggers habilitados)")
puts "#{Color::YELLOW}⚠ No se registraron triggers#{Color::RESET}"
puts "#{Color::DIM}Verifica que el sistema esté habilitado en adn/triggers.yml#{Color::RESET}"
end
end
# Limpia todos los triggers registrados
def limpiar_triggers
@logger.info("Limpiando triggers registrados...")
limpiados = @triggers.limpiar_triggers_registrados
@logger.exito("#{limpiados} trigger(s) limpiado(s) del bus de eventos")
puts "#{Color::GREEN}#{limpiados} trigger(s) limpiado(s)#{Color::RESET}"
puts "#{Color::DIM}Los triggers ya no responderán a eventos automáticamente#{Color::RESET}"
end
# Prueba un trigger específico
def probar_trigger
trigger_id = @opciones[:trigger_id]
unless trigger_id
@logger.error("Se requiere ID de trigger para probar (ej: bitacoras_0)")
puts "#{Color::RED}✗ Se requiere ID de trigger para probar#{Color::RESET}"
puts " Ejemplo: ./tools/adn/run triggers --test bitacoras_0"
puts " Usa --listar para ver IDs disponibles"
exit 1
end
@logger.info("Probando trigger: #{trigger_id}")
# Datos simulados para la prueba
datos_simulados = {
modo: 'prueba',
timestamp: Time.now.utc,
descripcion: "Datos simulados para prueba del trigger"
}
# Ejecutar prueba
resultado = @triggers.probar_trigger(trigger_id, datos_simulados)
# Mostrar resultados
mostrar_resultado_prueba(resultado)
end
# Ejecuta todos los triggers (modo batch)
def ejecutar_triggers
@logger.info("Ejecutando triggers en modo batch...")
resultados = @triggers.ejecutar_triggers(forzar: @opciones[:forzar])
mostrar_resultados_ejecucion(resultados)
end
# Muestra el resultado de una prueba de trigger
def mostrar_resultado_prueba(resultado)
puts "\n#{Color::CYAN}🧪 Resultado de prueba del trigger#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
if resultado[:exito]
puts " #{Color::GREEN}✓ Trigger probado exitosamente#{Color::RESET}"
puts " #{Color::DIM}ID:#{Color::RESET} #{resultado[:trigger]}"
puts " #{Color::DIM}Modo:#{Color::RESET} #{resultado[:accion_simulada][:modo]}"
puts " #{Color::DIM}Mensaje:#{Color::RESET} #{resultado[:accion_simulada][:mensaje]}"
else
puts " #{Color::RED}✗ Error probando trigger#{Color::RESET}"
puts " #{Color::DIM}ID:#{Color::RESET} #{resultado[:trigger]}"
puts " #{Color::DIM}Error:#{Color::RESET} #{resultado[:error]}"
end
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
puts "#{Color::DIM}Nota: Esta fue una prueba en modo simulación (sin efectos reales)#{Color::RESET}"
@logger.info("Prueba completada: #{resultado[:exito] ? 'éxito' : 'fallo'}")
end
# Muestra resultados de ejecución de triggers
def mostrar_resultados_ejecucion(resultados)
total = resultados[:total_ejecutados]
exitosos = resultados[:exitosos]
fallidos = resultados[:fallidos]
puts "\n#{Color::CYAN}📊 Resultados de ejecución de triggers#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
puts " #{Color::GREEN}Ejecutados:#{Color::RESET} #{total}"
puts " #{Color::GREEN}Exitosos:#{Color::RESET} #{exitosos}"
puts " #{Color::GREEN}Fallidos:#{Color::RESET} #{fallidos}"
if fallidos > 0
puts "\n#{Color::YELLOW}⚠ Triggers con errores:#{Color::RESET}"
resultados[:detalles].each do |detalle|
next if detalle[:exito]
puts " #{Color::RED}#{detalle[:trigger_id]}#{Color::RESET}"
puts " #{Color::DIM}Evento:#{Color::RESET} #{detalle[:evento]}"
puts " #{Color::DIM}Error:#{Color::RESET} #{detalle[:error]}"
end
end
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
if total == 0
@logger.advertencia("No se ejecutaron triggers (sistema deshabilitado)")
puts "#{Color::YELLOW} No se ejecutaron triggers#{Color::RESET}"
puts "#{Color::DIM}Usa --forzar para ejecutar de todos modos#{Color::RESET}"
elsif fallidos == 0
@logger.exito("Ejecución completada: #{exitosos}/#{total} triggers exitosos")
puts "#{Color::GREEN}✓ Ejecución completada sin errores#{Color::RESET}"
else
@logger.error("Ejecución completada con errores: #{fallidos}/#{total} fallidos")
puts "#{Color::RED}✗ Ejecución completada con #{fallidos} error(es)#{Color::RESET}"
end
end
end
end