Refactor(ADN): Retructuración del ecosistema ADN para unificar herramientas independientes. Fusión de w-zombi como adn/sondas y ns8-candados como adn/seguridad. Renombrado de comando/ a cli/ para consistencia y plan/parser.rb a parsers/plan.rb
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoAyuda
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty?
|
||||
mostrar_ayuda_principal
|
||||
else
|
||||
mostrar_ayuda_subcomando(@args[0])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda_principal
|
||||
puts <<~AYUDA_PRINCIPAL
|
||||
#{Color::CYAN}🔧 Herramienta ADN - Interfaz de línea de comandos unificada#{Color::RESET}
|
||||
#{Color::DIM}Versión: Fase 1 (Núcleo Autónomo)#{Color::RESET}
|
||||
|
||||
#{Color::GREEN}Uso:#{Color::RESET} ./tools/adn/run <subcomando> [opciones] [argumentos]
|
||||
|
||||
#{Color::YELLOW}Subcomandos disponibles:#{Color::RESET}
|
||||
validador [--watch] [--fix] Validar cumplimiento del ADN en bitácoras
|
||||
contexto <nodo> [--cmd] Obtener contexto topológico de un nodo
|
||||
salud [--dashboard] [--json] Ver salud general del sistema ADN
|
||||
generar bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD)
|
||||
generar nodo <nombre> Generar ficha de nodo
|
||||
#{Color::YELLOW}generar proyecto <código>#{Color::RESET} Generar manifiesto de proyecto
|
||||
#{Color::YELLOW}backup <nodo> [--mode MODO]#{Color::RESET} Ejecutar backup seguro de un nodo
|
||||
#{Color::YELLOW}inicio de jornada [presencial|remoto]#{Color::RESET} Inicio de jornada laboral
|
||||
#{Color::YELLOW}cierre de jornada [presencial|remoto]#{Color::RESET} Cierre de jornada laboral
|
||||
#{Color::YELLOW}triggers [opciones]#{Color::RESET} Gestión del motor de triggers
|
||||
#{Color::YELLOW}ayuda [subcomando]#{Color::RESET} Mostrar esta ayuda o ayuda específica
|
||||
|
||||
#{Color::YELLOW}Subcomandos en desarrollo (Fases futuras):#{Color::RESET}
|
||||
evolucion <propuesta.md> Gestionar propuesta de evolución del ADN
|
||||
triggers [--test] [--force] Ejecutar triggers de sincronización
|
||||
|
||||
#{Color::DIM}Para ayuda detallada de un subcomando:#{Color::RESET}
|
||||
./tools/adn/run ayuda <subcomando>
|
||||
|
||||
#{Color::DIM}Documentación completa:#{Color::RESET} docs/plan/260307-1400_Plan_Mejoras_ADN.md
|
||||
AYUDA_PRINCIPAL
|
||||
end
|
||||
|
||||
def mostrar_ayuda_subcomando(subcomando)
|
||||
case subcomando.downcase
|
||||
when 'validador'
|
||||
puts "Uso: ./tools/adn/run validador [--watch] [--fix]"
|
||||
puts ""
|
||||
puts "Valida el cumplimiento del ADN en las bitácoras:"
|
||||
puts " • Formato de tiempo (HH:MM, sin \"hs\")"
|
||||
puts " • Iconografía de estados y semántica correcta"
|
||||
puts " • Presencia de métricas híbridas ([Físico/Remoto:])"
|
||||
puts " • Referencias bidireccionales en rollovers"
|
||||
puts " • Estructura de tablas en bitácoras"
|
||||
puts ""
|
||||
puts "Opciones:"
|
||||
puts " --watch Monitorizar cambios en tiempo real (Fase 1 completa)"
|
||||
puts " --fix Intentar corregir errores automáticamente"
|
||||
puts ""
|
||||
puts "Ejemplo:"
|
||||
puts " ./tools/adn/run validador"
|
||||
puts " ./tools/adn/run validador --fix"
|
||||
when 'contexto'
|
||||
puts "Uso: ./tools/adn/run contexto <nombre-nodo> [--cmd]"
|
||||
puts ""
|
||||
puts "Obtiene información contextual de un nodo:"
|
||||
puts " • IP y configuración de red"
|
||||
puts " • Rol en la topología"
|
||||
puts " • Comandos SSH sugeridos"
|
||||
puts ""
|
||||
puts "Ejemplo:"
|
||||
puts " ./tools/adn/run contexto srv-pmox1"
|
||||
puts " ./tools/adn/run contexto srvv-fenix --cmd"
|
||||
when 'salud'
|
||||
puts "Uso: ./tools/adn/run salud [--dashboard] [--json]"
|
||||
puts ""
|
||||
puts "Muestra métricas de salud del sistema ADN:"
|
||||
puts " • Cantidad de hebras, bitácoras, nodos, proyectos"
|
||||
puts " • Errores recientes en logs"
|
||||
puts ""
|
||||
puts "Ejemplo:"
|
||||
puts " ./tools/adn/run salud"
|
||||
puts " ./tools/adn/run salud --json"
|
||||
when 'generar'
|
||||
puts "Uso: ./tools/adn/run generar <tipo> [argumentos]"
|
||||
puts ""
|
||||
puts "Tipos disponibles:"
|
||||
puts " bitacora [fecha] - Crea nueva bitácora (fecha: YYYY-MM-DD, default: hoy)"
|
||||
puts " nodo <nombre> - Crea ficha de nodo"
|
||||
puts " proyecto <código> - Crea manifiesto de proyecto"
|
||||
puts ""
|
||||
puts "Ejemplos:"
|
||||
puts " ./tools/adn/run generar bitacora"
|
||||
puts " ./tools/adn/run generar bitacora 2026-03-08"
|
||||
puts " ./tools/adn/run generar nodo srv-nuevo"
|
||||
puts " ./tools/adn/run generar proyecto P2604"
|
||||
else
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: #{subcomando}#{Color::RESET}"
|
||||
puts "Usa './tools/adn/run ayuda' para ver la lista de subcomandos."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,651 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require_relative '../core/configurador'
|
||||
|
||||
module ADN
|
||||
class SubcomandoBackup
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
|
||||
# Cargar valores predeterminados desde config.yml
|
||||
defaults = Configurador.obtener('backup.defaults') || {}
|
||||
@opciones = {
|
||||
modo: defaults['modo'] || 'stop',
|
||||
storage: defaults['storage'] || 'local',
|
||||
compress: defaults['compress'] || 'gzip',
|
||||
retencion: defaults['retencion_dias'] || 6
|
||||
}
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./tools/adn/run backup <nodo|estados> [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Ejecuta un backup remoto o consulta el estado actual."
|
||||
opts.separator ""
|
||||
opts.separator "Acciones:"
|
||||
opts.separator " <nodo> Ejecutar backup del nodo especificado"
|
||||
opts.separator " estados Ver estado actual de los backups en Proxmox"
|
||||
opts.separator " sanear Limpiar backups antiguos según retención"
|
||||
opts.separator " predeterminar Configurar valores por defecto"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--mode MODO", "Modo de backup (stop, snapshot, suspend. Default: #{@opciones[:modo]})") do |m|
|
||||
@opciones[:modo] = m
|
||||
end
|
||||
opts.on("--storage STORAGE", "Nombre del storage en Proxmox (Default: #{@opciones[:storage]})") do |s|
|
||||
@opciones[:storage] = s
|
||||
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 o acción")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
case @args[0]
|
||||
when 'predeterminar'
|
||||
comando_predeterminar(@args[1..-1])
|
||||
when 'estados', 'estado'
|
||||
comando_estados
|
||||
when 'sanear'
|
||||
comando_sanear
|
||||
else
|
||||
ejecutar_backup(@args[0])
|
||||
end
|
||||
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
|
||||
# Ejecutar con ns8-candados autorizando primero
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
task_id = nil
|
||||
full_output = ""
|
||||
|
||||
# Usar popen para leer la salida en tiempo real
|
||||
@logger.info("Conectando y monitoreando salida de Proxmox...")
|
||||
|
||||
IO.popen("ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}' 2>&1") do |pf|
|
||||
pf.each_line do |line|
|
||||
# Imprimir flujo para el usuario
|
||||
puts " #{Color::DIM}#{line.strip}#{Color::RESET}"
|
||||
full_output += line
|
||||
|
||||
# Si detectamos el Task ID al inicio, lo registramos de inmediato
|
||||
if task_id.nil? && match = line.match(/started backup task '([^']+)'/)
|
||||
task_id = match[1]
|
||||
@logger.info("Task ID detectado: #{task_id}. Registrando en bitácora...")
|
||||
registrar_task_id_bitacora(nodo, task_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
success = $?.success?
|
||||
|
||||
# 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, task_id)
|
||||
else
|
||||
@logger.error("La ejecución del backup falló.")
|
||||
registrar_fin_bitacora(nodo, false, task_id)
|
||||
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}")
|
||||
@logger.sugerencia("./tools/adn/run db nodo:crear --nombre #{nombre_nodo} --tipo servidor --ip 0.0.0.0 --desc \"Servidor a configurar\"")
|
||||
return nil
|
||||
end
|
||||
|
||||
contenido = File.read(ruta_nodo)
|
||||
vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1)
|
||||
host_name = contenido.match(/(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*:?\s*`([^`]+)`/i)&.[](1)
|
||||
|
||||
unless vmid && host_name
|
||||
@logger.error("Contexto incompleto en #{nombre_nodo}.md: VMID=#{vmid}, Host=#{host_name}")
|
||||
@logger.sugerencia([
|
||||
"Asegurate de que #{nombre_nodo}.md tenga las siguientes líneas:",
|
||||
"- **Padre/Host**: `srv-proxmox-ejemplo`",
|
||||
"- **VMID**: `1xx`"
|
||||
])
|
||||
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)
|
||||
host_ip ||= c_host.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1)
|
||||
host_ip ||= c_host.match(/IP\*\*:?\s*`([^`]+)`/)&.[](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}")
|
||||
@logger.sugerencia([
|
||||
"Verificá que el archivo #{host_name}.md exista.",
|
||||
"Si no existe, crealo con: ./tools/adn/run db nodo:crear --nombre #{host_name} --tipo servidor --ip DIRECCION_IP",
|
||||
"Asegurate de que incluya: **IP**: `DIRECCION_IP`"
|
||||
])
|
||||
return nil
|
||||
end
|
||||
|
||||
host_ip = host_ip.split('/')[0] if host_ip.include?('/')
|
||||
{ vmid: vmid, host_name: host_name, host_ip: host_ip }
|
||||
end
|
||||
|
||||
def registrar_inicio_bitacora(nodo, datos)
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# Asegurar bitácora
|
||||
bitacora = db.find_bitacora_by_fecha(Date.today)
|
||||
bitacora ||= db.create_bitacora(fecha: Date.today)
|
||||
|
||||
# Buscar nodo en DB
|
||||
nodo_db = db.find_nodo_by_nombre(nodo)
|
||||
|
||||
hora = Time.now.strftime('%H:%M')
|
||||
entrada = db.create_entrada(
|
||||
inicio: hora,
|
||||
descripcion: "Inicio de backup remoto vía SSH para VM #{datos[:vmid]} en #{datos[:host_name]}.",
|
||||
estado: '⏳',
|
||||
modo: 'R',
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo_db ? nodo_db['id'] : nil
|
||||
)
|
||||
|
||||
# Guardar ID para actualizar después
|
||||
@backup_entrada_id = entrada['id'].to_i
|
||||
@backup_bitacora_id = bitacora['id'].to_i
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error registrando inicio en DB: #{e.message}")
|
||||
end
|
||||
|
||||
def registrar_task_id_bitacora(nodo, task_id)
|
||||
return unless @backup_entrada_id
|
||||
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
db.update_entrada(@backup_entrada_id, {
|
||||
descripcion: "[task:#{task_id[0..7]}] Backup remoto en ejecución."
|
||||
})
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error registrando task_id en DB: #{e.message}")
|
||||
end
|
||||
|
||||
def registrar_fin_bitacora(nodo, exito, task_id = nil)
|
||||
return unless @backup_entrada_id
|
||||
|
||||
require_relative '../db/core/bitacora_db'
|
||||
require_relative '../db/exporters/md_exporter'
|
||||
|
||||
hora_fin = Time.now.strftime('%H:%M')
|
||||
estado = exito ? '✅' : '⚠️'
|
||||
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
|
||||
desc = "[task:#{task_id[0..7]}] #{desc}" if task_id
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
db.update_entrada(@backup_entrada_id, {
|
||||
fin: hora_fin,
|
||||
descripcion: desc,
|
||||
estado: estado
|
||||
})
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error registrando fin en DB: #{e.message}")
|
||||
end
|
||||
|
||||
|
||||
def comando_predeterminar(args)
|
||||
if args.length < 2
|
||||
puts "#{Color::YELLOW}Uso: adn backup predeterminar <parametro> <valor>#{Color::RESET}"
|
||||
puts "Ej: adn backup predeterminar modo snapshot"
|
||||
exit 1
|
||||
end
|
||||
|
||||
param = args[0].downcase
|
||||
valor = args[1]
|
||||
param = 'modo' if param == 'mode'
|
||||
param = 'retencion_dias' if param == 'edad'
|
||||
|
||||
unless ['modo', 'storage', 'compress', 'retencion_dias'].include?(param)
|
||||
@logger.error("Parámetro no predeterminable: #{param}")
|
||||
exit 1
|
||||
end
|
||||
|
||||
Configurador.establecer("backup.defaults.#{param}", valor)
|
||||
@logger.exito("Valor predeterminado establecido: backup.#{param} = #{valor}")
|
||||
puts "#{Color::GREEN}✓ Se ha predeterminado el #{param} a '#{valor}' para futuros backups.#{Color::RESET}"
|
||||
end
|
||||
|
||||
def comando_estados
|
||||
@logger.info("Consultando estado de backups en cluster Proxmox")
|
||||
|
||||
# Lista de hosts a consultar
|
||||
hosts = [
|
||||
{ nombre: 'srv-pmox1', ip: '10.0.10.201' },
|
||||
{ nombre: 'srv-dasu', ip: '10.0.10.205' }
|
||||
]
|
||||
|
||||
puts "\n#{Color::BOLD}🔍 Estado de Backups en Tiempo Real (Proxmox)#{Color::RESET}"
|
||||
puts "─" * 60
|
||||
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
|
||||
# Asegurar autorización general para el lote
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
all_tasks_accumulated = []
|
||||
|
||||
hosts.each do |h|
|
||||
print "📡 Conectando a #{h[:nombre]} (#{h[:ip]})... "
|
||||
|
||||
# Intentamos obtener tareas
|
||||
cmd = "/usr/bin/pvesh get /cluster/tasks --output-format json"
|
||||
cmd_seguro = "ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'"
|
||||
|
||||
# Ejecutamos vía candados run
|
||||
output = `ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}' 2>&1`
|
||||
|
||||
if output.include?('[')
|
||||
begin
|
||||
# Extraer el JSON
|
||||
json_str = output[output.index('[')..output.rindex(']')]
|
||||
tasks_host = JSON.parse(json_str)
|
||||
all_tasks_accumulated.concat(tasks_host)
|
||||
|
||||
# Filtrar tareas vzdump activas de ESTE host para mostrar
|
||||
tasks_activas = tasks_host.select { |t| t['type'] == 'vzdump' && t['endtime'].nil? }
|
||||
|
||||
puts "#{Color::GREEN}OK#{Color::RESET}"
|
||||
if tasks_activas.empty?
|
||||
puts " #{Color::DIM}Sin tareas de backup activas en este host.#{Color::RESET}"
|
||||
else
|
||||
tasks_activas.each do |t|
|
||||
msg = t['status'] || 'ejecutando'
|
||||
print " ▶ #{Color::CYAN}#{t['id']}#{Color::RESET} "
|
||||
puts "[#{msg}] (Iniciado: #{Time.at(t['starttime']).strftime('%H:%M:%S')})"
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
puts "#{Color::YELLOW}JSON ERROR#{Color::RESET}"
|
||||
puts " #{Color::DIM}Error parseando respuesta: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
else
|
||||
puts "#{Color::RED}ERROR#{Color::RESET}"
|
||||
if output.include?('Permission denied')
|
||||
puts " #{Color::DIM}Error de permisos/candado puesto.#{Color::RESET}"
|
||||
else
|
||||
puts " #{Color::DIM}No se detectaron tareas activas o el host no respondió.#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
|
||||
# 2. Sincronización Automática: Auto-reconciliación y Migración
|
||||
fecha_hoy = Date.today.iso8601
|
||||
archivo_md = File.join(ADN::BITACORAS_DIR, "#{fecha_hoy}.md")
|
||||
|
||||
if File.exist?(archivo_md)
|
||||
print "#{Color::DIM}🔄 Reconciliando tareas pendientes... #{Color::RESET}"
|
||||
auto_reconciliar_bitacora(all_tasks_accumulated)
|
||||
puts "#{Color::DIM}OK#{Color::RESET}"
|
||||
|
||||
print "#{Color::DIM}🔄 Sincronizando bitácora digital... #{Color::RESET}"
|
||||
# Ejecutamos la migración de forma silenciosa
|
||||
cmd_migrar = "#{File.join(ADN::PROJECT_ROOT, 'tools/adn/run')} db migrar:md #{fecha_hoy} --sobrescribir > /dev/null 2>&1"
|
||||
system(cmd_migrar)
|
||||
puts "#{Color::DIM}OK#{Color::RESET}"
|
||||
end
|
||||
|
||||
# 3. Historial de la DB (Sincronizado)
|
||||
puts "\n#{Color::BOLD}📅 Historial de hoy (Sincronizado)#{Color::RESET}"
|
||||
puts "─" * 60
|
||||
|
||||
db_tool = "./tools/adn/run"
|
||||
# Buscamos por "backup" o "BKP"
|
||||
system("#{db_tool} db evento:listar --desde '#{fecha_hoy}' --buscar 'ackup'")
|
||||
|
||||
puts "\n#{Color::DIM}Para historial completo use: ./tools/adn/run db evento:listar --desde '#{fecha_hoy}'#{Color::RESET}"
|
||||
end
|
||||
|
||||
def comando_sanear
|
||||
dias = @opciones[:retencion].to_i
|
||||
storage = @opciones[:storage]
|
||||
@logger.info("Iniciando saneamiento de backups (Retención: #{dias} días en '#{storage}')")
|
||||
|
||||
# 1. Obtener lista de VMs directamente de Proxmox para nombres precisos
|
||||
host = { nombre: 'srv-pmox1', ip: '10.0.10.201' }
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
@logger.info("Consultando inventario de VMs en Proxmox...")
|
||||
cmd_vms = "/usr/bin/pvesh get /cluster/resources --type vm --output-format json"
|
||||
output_vms = `ruby #{candados_path} run admindasu SSHPASS 'ssh -o StrictHostKeyChecking=no root@#{host[:ip]} "#{cmd_vms}"' 2>/dev/null`
|
||||
|
||||
vmid_map = cargar_mapa_vmid_nodos # Cargar local primero
|
||||
|
||||
begin
|
||||
if output_vms.include?('[')
|
||||
json_vms = output_vms[output_vms.index('[')..output_vms.rindex(']')]
|
||||
JSON.parse(json_vms).each do |vm|
|
||||
id = vm['vmid'].to_s
|
||||
# Priorizar nombre de Proxmox si el local no existe o es genérico
|
||||
vmid_map[id] = vm['name'] if vm['name'] && (!vmid_map[id] || vmid_map[id] == id)
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
@logger.advertencia("No se pudo completar el mapa de VMs desde Proxmox: #{e.message}")
|
||||
end
|
||||
|
||||
# 2. Obtener lista de archivos en el storage
|
||||
# Intentar con varios hosts del cluster por si uno está caído
|
||||
ruta_dump = "/mnt/pve/#{storage}/dump"
|
||||
@logger.info("Listando archivos en #{storage}...")
|
||||
|
||||
output_ls = ""
|
||||
[ '10.0.10.201', '10.0.10.202', '10.0.10.203' ].each do |ip|
|
||||
cmd_ls = "ls -1 --full-time #{ruta_dump}/vzdump-*"
|
||||
res = `ruby #{candados_path} run admindasu SSHPASS 'ssh -o StrictHostKeyChecking=no root@#{ip} "#{cmd_ls}"' 2>&1`
|
||||
unless res.include?('No such file') || res.include?('Connection refused')
|
||||
output_ls = res
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if output_ls.empty? || output_ls.include?('No such file')
|
||||
@logger.error("No se encontró la ruta de backups en ningún host: #{ruta_dump}")
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
exit 1
|
||||
end
|
||||
|
||||
# 3. Filtrar archivos por antigüedad
|
||||
ahora = Time.now
|
||||
limite = ahora - (dias * 24 * 60 * 60)
|
||||
|
||||
backups_agrupados = {}
|
||||
|
||||
output_ls.split("\n").each do |linea|
|
||||
next if linea.include?('OPTIONS]') || linea.empty?
|
||||
|
||||
partes = linea.split(/\s+/)
|
||||
next if partes.length < 9
|
||||
|
||||
fecha_str = "#{partes[5]} #{partes[6]}"
|
||||
ruta_archivo = partes[8..-1].join(' ')
|
||||
nombre = File.basename(ruta_archivo)
|
||||
|
||||
fingerprint = nombre.sub(/\.(log|vma\.zst|tar\.zst|vma\.zst\.notes|tar\.zst\.notes)$/, '')
|
||||
|
||||
begin
|
||||
mtime = Time.parse(fecha_str)
|
||||
if mtime < limite
|
||||
vmid = fingerprint.match(/vzdump-(?:qemu|lxc)-(\d+)-/)&.[](1)
|
||||
nodo_humano = vmid ? vmid_map[vmid] : "ID:#{vmid}"
|
||||
|
||||
backups_agrupados[fingerprint] ||= {
|
||||
fingerprint: fingerprint,
|
||||
fecha: mtime,
|
||||
vmid: vmid,
|
||||
nodo: nodo_humano,
|
||||
archivos: []
|
||||
}
|
||||
backups_agrupados[fingerprint][:archivos] << { ruta: ruta_archivo, nombre: nombre }
|
||||
end
|
||||
rescue
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
if backups_agrupados.empty?
|
||||
puts "#{Color::GREEN}✓ No se encontraron backups mayores a #{dias} días en #{storage}.#{Color::RESET}"
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
return
|
||||
end
|
||||
|
||||
# Convertir a array ordenado por fecha
|
||||
lista_final = backups_agrupados.values.sort_by { |b| b[:fecha] }
|
||||
|
||||
# 4. Mostrar lista con nuevo formato solicitado
|
||||
puts "\n#{Color::BOLD}🗑️ Propuesta de Saneamiento de Backups#{Color::RESET} (Retención: #{dias} días)"
|
||||
puts "─" * 90
|
||||
puts "#{Color::BOLD}ID Nombre_VMID_Fecha Componentes detectados#{Color::RESET}"
|
||||
puts "─" * 90
|
||||
|
||||
lista_final.each_with_index do |b, idx|
|
||||
num = (idx + 1).to_s.rjust(2, '0')
|
||||
fecha_corta = b[:fecha].strftime('%Y-%m-%d')
|
||||
|
||||
# Formato solicitado: XX - VMNombre_VMid_Fecha
|
||||
entrada_label = "#{num} - #{Color::BOLD}#{Color::CYAN}#{b[:nodo]}_#{b[:vmid]}_#{fecha_corta}#{Color::RESET}".ljust(60)
|
||||
|
||||
tipos = b[:archivos].map { |a| a[:nombre].split('.').last }.uniq.join(", ")
|
||||
total_archivos = "(#{b[:archivos].length} arch.)"
|
||||
|
||||
puts "#{entrada_label} #{Color::DIM}#{tipos.ljust(15)} #{total_archivos}#{Color::RESET}"
|
||||
end
|
||||
puts "─" * 90
|
||||
puts "Total detectado: #{lista_final.length} backups completos (#{backups_agrupados.values.sum { |b| b[:archivos].length }} archivos en total)."
|
||||
|
||||
puts "\n#{Color::BOLD}Acciones:#{Color::RESET}"
|
||||
puts " • Escriba #{Color::BOLD}'S'#{Color::RESET} para borrar TODOS los backups listados."
|
||||
puts " • Escriba #{Color::BOLD}excluir 1, 3-5#{Color::RESET} para conservar backups específicos."
|
||||
puts " • Escriba #{Color::BOLD}'N'#{Color::RESET} para cancelar."
|
||||
|
||||
print "\n👉 #{Color::BOLD}¿Qué deseas hacer?#{Color::RESET}: "
|
||||
input = STDIN.gets.chomp.strip.downcase
|
||||
|
||||
if input == 'n' || input == ''
|
||||
puts "#{Color::YELLOW}Operación cancelada.#{Color::RESET}"
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
exit
|
||||
end
|
||||
|
||||
# Procesar exclusiones
|
||||
seleccionados = lista_final.dup
|
||||
if input.start_with?('excluir')
|
||||
exclusiones = []
|
||||
patron = input.sub('excluir', '').strip
|
||||
|
||||
patron.split(/[\s,]+/).each do |p|
|
||||
next if p.empty?
|
||||
if p.include?('-')
|
||||
inicio, fin = p.split('-').map(&:to_i)
|
||||
exclusiones.concat((inicio..fin).to_a)
|
||||
else
|
||||
exclusiones << p.to_i
|
||||
end
|
||||
end
|
||||
|
||||
seleccionados = lista_final.reject.with_index { |_, idx| exclusiones.include?(idx + 1) }
|
||||
|
||||
if seleccionados.empty?
|
||||
puts "#{Color::YELLOW}Has excluido todos los backups. Nada que hacer.#{Color::RESET}"
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
exit
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}ℹ Has excluido #{lista_final.length - seleccionados.length} backups. Procediendo con #{seleccionados.length}...#{Color::RESET}"
|
||||
elsif input != 's'
|
||||
puts "#{Color::RED}Opción no válida. Cancelando.#{Color::RESET}"
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
exit
|
||||
end
|
||||
|
||||
# 5. Borrado final
|
||||
total_a_borrar = seleccionados.sum { |b| b[:archivos].length }
|
||||
puts "\n#{Color::BOLD}🗑️ Borrando #{total_a_borrar} archivos asociados a #{seleccionados.length} backups...#{Color::RESET}"
|
||||
|
||||
seleccionados.each_with_index do |b, i|
|
||||
puts "\n #{Color::BOLD}[#{i+1}/#{seleccionados.length}] Procesando #{b[:nodo]}...#{Color::RESET}"
|
||||
b[:archivos].each do |a|
|
||||
print " Eliminando #{a[:nombre]}... "
|
||||
del_cmd = "rm -f '#{a[:ruta]}'"
|
||||
del_seguro = "ssh -o StrictHostKeyChecking=no root@#{host[:ip]} '#{del_cmd}'"
|
||||
system("ruby #{candados_path} run admindasu SSHPASS '#{del_seguro}' > /dev/null 2>&1")
|
||||
puts "#{Color::GREEN}OK#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
@logger.exito("Saneamiento completado. Se limpiaron #{seleccionados.length} sets de backup.")
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
end
|
||||
|
||||
def cargar_mapa_vmid_nodos
|
||||
mapa = {}
|
||||
Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).each do |f|
|
||||
next if File.directory?(f)
|
||||
begin
|
||||
contenido = File.read(f)
|
||||
# Buscar nombre en h1 o metadatos
|
||||
nombre = File.basename(f, ".md")
|
||||
vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1)
|
||||
mapa[vmid.to_s] = nombre if vmid
|
||||
rescue
|
||||
next
|
||||
end
|
||||
end
|
||||
mapa
|
||||
end
|
||||
|
||||
def auto_reconciliar_bitacora(all_tasks)
|
||||
fecha = Date.today.iso8601
|
||||
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
|
||||
return unless File.exist?(ruta)
|
||||
|
||||
contenido = File.read(ruta)
|
||||
vmid_map = cargar_mapa_vmid_nodos
|
||||
|
||||
# Encontrar todas las filas que tienen un reloj de arena
|
||||
lineas = contenido.lines
|
||||
nodo_actual = nil
|
||||
|
||||
lineas.each do |linea|
|
||||
if linea.start_with?('### srvv-')
|
||||
nodo_actual = linea.sub('### ', '').strip
|
||||
next
|
||||
end
|
||||
|
||||
# Si es una fila de tabla en progreso (⏳)
|
||||
if nodo_actual && linea.include?('|') && linea.include?('⏳')
|
||||
match_hora = linea.match(/\|\s*(\d{2}:\d{2})\s*\|/)
|
||||
next unless match_hora
|
||||
|
||||
hora_inicio_str = match_hora[1]
|
||||
vmid = vmid_map.key(nodo_actual)
|
||||
next unless vmid
|
||||
|
||||
# Buscar última tarea vzdump de este VMID que ya terminó
|
||||
hora_inicio_t = Time.parse("#{fecha} #{hora_inicio_str}") rescue nil
|
||||
|
||||
tarea = all_tasks.select { |t|
|
||||
vmid_task = (t['vmid'] || t['id']).to_s
|
||||
t['type'] == 'vzdump' &&
|
||||
vmid_task == vmid &&
|
||||
!t['endtime'].nil? &&
|
||||
(hora_inicio_t ? Time.at(t['starttime']) >= (hora_inicio_t - 3600) : true)
|
||||
}.max_by { |t| t['endtime'] }
|
||||
|
||||
if tarea
|
||||
exito = (tarea['status'] == 'OK')
|
||||
hora_fin = Time.at(tarea['endtime']).strftime('%H:%M')
|
||||
|
||||
@logger.info("Auto-cerrando backup de #{nodo_actual} (Task ID: #{tarea['id']})")
|
||||
cerrar_entrada_especifica(nodo_actual, hora_inicio_str, exito, tarea['id'], hora_fin)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cerrar_entrada_especifica(nodo, hora_ini, exito, task_id, hora_fin)
|
||||
fecha = Date.today.iso8601
|
||||
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
|
||||
return unless File.exist?(ruta)
|
||||
|
||||
estado_ico = exito ? '✅' : '⚠️'
|
||||
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
|
||||
desc = "[task:#{task_id[0..7]}] #{desc}" if task_id
|
||||
|
||||
linea_nueva = "| #{hora_ini} | #{hora_fin} | #{desc} | R | #{estado_ico} |"
|
||||
|
||||
contenido = File.read(ruta)
|
||||
# Buscamos la fila específica que coincida con el nodo Y la hora de inicio
|
||||
# Capturamos el bloque del nodo para evitar errores si otros nodos tienen la misma hora
|
||||
regex_seccion = /(### #{Regexp.escape(nodo)}.*?)(?=^###\s|<!--|\z)/m
|
||||
seccion = contenido.match(regex_seccion)&.[](0)
|
||||
unless seccion
|
||||
return
|
||||
end
|
||||
|
||||
# Regex para la fila exacta que tenga la hora de inicio Y un reloj ⏳ en la última columna
|
||||
regex_fila = /\|\s*#{Regexp.escape(hora_ini)}\s*\|\s*\|\s*[^|]*\|\s*[^|]*\|\s*[^|]*⏳[^|]*\|/
|
||||
|
||||
if seccion.match?(regex_fila)
|
||||
@logger.exito("Reconciliado: #{nodo} (#{hora_ini} -> #{hora_fin})")
|
||||
nueva_seccion = seccion.sub(regex_fila, linea_nueva)
|
||||
# Si ya no quedan ⏳ en esta sección, actualizamos su icono #### ⏳ -> ✅
|
||||
# Buscamos cualquier reloj de arena que quede en la sección
|
||||
unless nueva_seccion.include?('⏳')
|
||||
nueva_seccion.gsub!(/#### [⏳⏳] - BKP-ADN/, "#### #{estado_ico} - BKP-ADN")
|
||||
end
|
||||
|
||||
nuevo_contenido = contenido.gsub(seccion, nueva_seccion)
|
||||
File.write(ruta, nuevo_contenido)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# tools/adn/cli/cierre.rb — Subcomando para cierre de jornada laboral (DB-First)
|
||||
# ==============================================================================
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
module ADN
|
||||
class SubcomandoCierre
|
||||
require_relative '../core/colores'
|
||||
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@fecha = Date.today
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || !(@args.include?('jornada'))
|
||||
puts "#{Color::YELLOW}Uso: ./tools/adn/run cierre de jornada [presencial|remoto]#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
modo = @args.include?('presencial') ? 'P' : 'R'
|
||||
tipo_str = modo == 'P' ? 'Presencial' : 'Remoto'
|
||||
hora_fin = Time.now.strftime('%H:%M')
|
||||
|
||||
@logger.info("Iniciando cierre de jornada #{tipo_str} (Modo DB-First)")
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# 1. Buscar la bitácora
|
||||
bitacora = db.find_bitacora_by_fecha(@fecha)
|
||||
unless bitacora
|
||||
@logger.error("No existe bitácora en DB para hoy (#{@fecha}). ¿Iniciaste la jornada?")
|
||||
return
|
||||
end
|
||||
|
||||
# 2. Obtener nodo srv-ns8
|
||||
nodo = db.find_nodo_by_nombre('srv-ns8')
|
||||
|
||||
# 3. Crear entrada de cierre
|
||||
db.create_entrada(
|
||||
inicio: hora_fin,
|
||||
fin: hora_fin,
|
||||
descripcion: "Cierre de jornada #{tipo_str.downcase}. Sincronización final y validación de backups.",
|
||||
estado: '✅',
|
||||
modo: modo,
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo ? nodo['id'] : nil
|
||||
)
|
||||
|
||||
@logger.exito("Cierre de jornada registrado en Base de Datos.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# tools/adn/cli/conocimiento.rb — Subcomando para gestionar la Base de Conocimiento del ADN
|
||||
# ==============================================================================
|
||||
# Inyecta nuevos documentos y los referencia automáticamente en el 00_indice.md
|
||||
# cumpliendo con el principio de Armonía Integral.
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
|
||||
module ADN
|
||||
class SubcomandoConocimiento
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@indice_path = File.join(PROJECT_ROOT, 'adn', '00_indice.md')
|
||||
@conocimiento_dir = File.join(PROJECT_ROOT, 'adn', 'conocimiento')
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args[0] == 'help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
subcomando = @args[0]
|
||||
sub_args = @args[1..-1] || []
|
||||
|
||||
case subcomando
|
||||
when 'asimilar'
|
||||
comando_asimilar(sub_args)
|
||||
else
|
||||
@logger.error("Subcomando desconocido: conocimiento #{subcomando}")
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Operación interrumpida#{Color::RESET}"
|
||||
exit 130
|
||||
rescue StandardError => e
|
||||
@logger.error("Error asimilando conocimiento: #{e.message}")
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
|
||||
exit 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def comando_asimilar(args)
|
||||
opts = { forzar: false }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn conocimiento asimilar <archivo.md> [OPCIONES]"
|
||||
opt.on("-f", "--force", "Forzar sobrescritura si el documento ya está asimilado") do
|
||||
opts[:forzar] = 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
|
||||
|
||||
archivo_origen = args[0]
|
||||
|
||||
if archivo_origen.nil? || archivo_origen.strip.empty?
|
||||
puts "#{Color::RED}✗ Se requiere la ruta de un archivo Markdown.#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
unless File.exist?(archivo_origen)
|
||||
puts "#{Color::RED}✗ El archivo origen no existe: #{archivo_origen}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🧠 Asimilando conocimiento desde #{archivo_origen}...#{Color::RESET}"
|
||||
|
||||
# Preparar el directorio de conocimiento
|
||||
FileUtils.mkdir_p(@conocimiento_dir) unless Dir.exist?(@conocimiento_dir)
|
||||
|
||||
nombre_base = File.basename(archivo_origen)
|
||||
archivo_destino = File.join(@conocimiento_dir, nombre_base)
|
||||
|
||||
if File.exist?(archivo_destino) && !opts[:forzar]
|
||||
puts "#{Color::YELLOW}⚠ El documento ya existe en la base de ADN. Usa --force para sobrescribir.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Copiar el archivo
|
||||
FileUtils.cp(archivo_origen, archivo_destino)
|
||||
puts " #{Color::DIM}Documento integrado físicamente en: adn/conocimiento/#{nombre_base}#{Color::RESET}"
|
||||
|
||||
# Extraer metadatos
|
||||
contenido = File.read(archivo_destino)
|
||||
titulo = contenido.match(/^#\s+(.+)$/)&.captures&.first || nombre_base.sub('.md', '').capitalize
|
||||
|
||||
# Buscar el primer párrafo que no sea un título ni un bloque especial
|
||||
resumen = "Asimilación automatizada de módulo."
|
||||
parrafos = contenido.split("\n\n").reject { |p| p.start_with?('#', '>', '<', '---', '!') || p.strip.empty? }
|
||||
if parrafos.any?
|
||||
resumen_limpio = parrafos.first.gsub("\n", " ").strip
|
||||
resumen = resumen_limpio[0..147] + "..." if resumen_limpio.length > 150
|
||||
resumen = resumen_limpio if resumen_limpio.length <= 150
|
||||
end
|
||||
|
||||
# Actualizar 00_indice.md
|
||||
actualizar_indice(nombre_base, titulo, resumen)
|
||||
|
||||
puts "#{Color::GREEN}✅ Conocimiento asimilado correctamente y propagado a ADN/00_indice.md#{Color::RESET}"
|
||||
@logger.info("Conocimiento asimilado: #{nombre_base}")
|
||||
end
|
||||
|
||||
def actualizar_indice(nombre_archivo, titulo, resumen)
|
||||
contenido_indice = File.read(@indice_path)
|
||||
fecha_hoy = Date.today.strftime("%d/%m/%Y")
|
||||
ruta_relativa = "conocimiento/#{nombre_archivo}"
|
||||
|
||||
fila_markdown = "| [**#{titulo}**](#{ruta_relativa}) | #{fecha_hoy} | #{resumen} |"
|
||||
|
||||
# Comprobar si ya existe la sección de conocimiento
|
||||
seccion_conocimiento = "## 📚 Base de Conocimiento"
|
||||
|
||||
if contenido_indice.include?(seccion_conocimiento)
|
||||
# Revisión de duplicados
|
||||
return if contenido_indice.include?(ruta_relativa) && !contenido_indice.include?("Sobrescribir")
|
||||
|
||||
# Encontrar el final de la tabla e insertar la fila (reemplazando si hace falta actualizar)
|
||||
lineas = contenido_indice.lines
|
||||
idx_seccion = lineas.index { |l| l.include?(seccion_conocimiento) }
|
||||
|
||||
# Insertar después de las cabeceras de la tabla
|
||||
idx_insercion = idx_seccion + 4
|
||||
|
||||
lineas.insert(idx_insercion, "#{fila_markdown}\n")
|
||||
File.write(@indice_path, lineas.join)
|
||||
else
|
||||
# Inyectar toda la sección al final del archivo
|
||||
bloque_nuevo = <<~MD
|
||||
|
||||
---
|
||||
|
||||
#{seccion_conocimiento}
|
||||
|
||||
| Documento | Fecha de Asimilación | Descripción |
|
||||
| :--- | :--- | :--- |
|
||||
#{fila_markdown}
|
||||
|
||||
MD
|
||||
|
||||
File.open(@indice_path, 'a') do |f|
|
||||
f.puts bloque_nuevo
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts "#{Color::CYAN}🧠 Herramienta ADN - Gestión de Base de Conocimiento#{Color::RESET}"
|
||||
puts "Permite inyectar, organizar e indexar nueva documentación en el motor autónomo."
|
||||
puts ""
|
||||
puts "#{Color::BOLD}SUBCOMANDOS:#{Color::RESET}"
|
||||
puts " #{Color::CYAN}asimilar <archivo.md>#{Color::RESET} Ingiere un documento Markdown a la base cognitiva del ADN."
|
||||
puts ""
|
||||
puts "#{Color::BOLD}EJEMPLOS:#{Color::RESET}"
|
||||
puts " ./tools/adn/run conocimiento asimilar docs/configuracion_bd.md"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoContexto
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = { cmd: false }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere nombre de nodo")
|
||||
puts "Uso: ./tools/adn/run contexto <nombre-nodo> [--cmd]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
nodo = @args[0]
|
||||
@args.shift
|
||||
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./tools/adn/run contexto <nombre-nodo> [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Obtiene contexto topológico de un nodo"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--cmd", "Generar comando SSH con ProxyJump si es necesario") do
|
||||
@opciones[:cmd] = true
|
||||
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
|
||||
|
||||
obtener_contexto(nodo)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def obtener_contexto(nombre_nodo)
|
||||
@logger.info("Buscando contexto para nodo: #{nombre_nodo}")
|
||||
|
||||
ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre_nodo}.md")
|
||||
|
||||
unless File.exist?(ruta_nodo)
|
||||
@logger.error("Nodo no encontrado: #{nombre_nodo}")
|
||||
puts "#{Color::RED}✗ No se encontró ficha para el nodo '#{nombre_nodo}'#{Color::RESET}"
|
||||
puts " Buscado en: #{ruta_nodo}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
contenido = File.read(ruta_nodo, encoding: 'UTF-8')
|
||||
|
||||
# Extraer información básica
|
||||
puts "#{Color::CYAN}📋 Contexto del nodo: #{nombre_nodo}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
# Buscar IP en la tabla
|
||||
if contenido.match(/\|\s*IP (?:LAN|Pública)?\s*\|\s*([^\|]+)\s*\|/)
|
||||
ip = $1.strip
|
||||
puts " #{Color::GREEN}IP:#{Color::RESET} #{ip}"
|
||||
|
||||
if @opciones[:cmd]
|
||||
generar_comando_ssh(nombre_nodo, ip)
|
||||
end
|
||||
end
|
||||
|
||||
# Buscar rol
|
||||
if contenido.match(/\|\s*Rol(?: Principal)?\s*\|\s*([^\|]+)\s*\|/)
|
||||
rol = $1.strip
|
||||
puts " #{Color::GREEN}Rol:#{Color::RESET} #{rol}"
|
||||
end
|
||||
|
||||
# Buscar sistema operativo
|
||||
if contenido.match(/\|\s*Sistema Operativo\s*\|\s*([^\|]+)\s*\|/)
|
||||
so = $1.strip
|
||||
puts " #{Color::GREEN}SO:#{Color::RESET} #{so}"
|
||||
end
|
||||
|
||||
# Buscar en ontología para contexto de red
|
||||
config_path = File.join(__dir__, '../config', 'config.yml')
|
||||
ontologia_path = File.join(ADN::PROJECT_ROOT, 'adn', '01_ontologia.md')
|
||||
if File.exist?(ontologia_path)
|
||||
ontologia = File.read(ontologia_path, encoding: 'UTF-8')
|
||||
if ontologia.include?(nombre_nodo)
|
||||
puts " #{Color::GREEN}En ontología:#{Color::RESET} Sí"
|
||||
|
||||
# Intentar encontrar en qué cluster está
|
||||
clusters = ['srv-pmox1', 'srv-pmox2', 'srv-pmox3', 'srv-xen1', 'srv-dasu']
|
||||
cluster = clusters.find { |c| ontologia.include?("**#{c}**") && ontologia.include?(nombre_nodo) }
|
||||
|
||||
if cluster
|
||||
puts " #{Color::GREEN}Cluster:#{Color::RESET} #{cluster}"
|
||||
|
||||
# Determinar si necesita ProxyJump
|
||||
if cluster != 'srv-ns8' && !nombre_nodo.start_with?('srv-')
|
||||
puts " #{Color::YELLOW}🔧 Acceso probable via ProxyJump:#{Color::RESET}"
|
||||
puts " ssh -J root@#{cluster} rmonla@#{nombre_nodo}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
@logger.exito("Contexto obtenido para nodo: #{nombre_nodo}")
|
||||
end
|
||||
|
||||
def generar_comando_ssh(nodo, ip)
|
||||
puts "\n#{Color::CYAN}🔧 Comando SSH sugerido:#{Color::RESET}"
|
||||
|
||||
# Determinar usuario basado en el nodo
|
||||
usuario = if nodo.start_with?('srvv-') || nodo.start_with?('pcv-')
|
||||
'rmonla'
|
||||
elsif nodo.start_with?('srv-') && nodo != 'srv-ns8'
|
||||
'root'
|
||||
else
|
||||
'rmonla'
|
||||
end
|
||||
|
||||
puts " ssh #{usuario}@#{ip}"
|
||||
|
||||
# Si no es srv-ns8 y no es un hypervisor, sugerir usando nombre
|
||||
unless nodo == 'srv-ns8' || nodo.start_with?('srv-pmox') || nodo.start_with?('srv-xen')
|
||||
puts " #{Color::DIM}O usando nombre (si está en ~/.ssh/config):#{Color::RESET}"
|
||||
puts " ssh #{nodo}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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 '../db/core/bitacora_db'
|
||||
require_relative '../db/importers/md_importer'
|
||||
require_relative '../db/exporters/md_exporter'
|
||||
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/adn/db/ existen"
|
||||
end
|
||||
|
||||
module ADN
|
||||
class SubcomandoDB
|
||||
require_relative 'db/bitacora'
|
||||
require_relative 'db/evento'
|
||||
require_relative 'db/nodo'
|
||||
require_relative 'db/hito'
|
||||
require_relative 'db/misc'
|
||||
include SubcomandoDBBitacora
|
||||
include SubcomandoDBEvento
|
||||
include SubcomandoDBNodo
|
||||
include SubcomandoDBHito
|
||||
include SubcomandoDBMisc
|
||||
# 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 'evento:actualizar'
|
||||
comando_evento_actualizar(sub_args)
|
||||
when 'nodo:crear'
|
||||
comando_nodo_crear(sub_args)
|
||||
when 'nodo:listar'
|
||||
comando_nodo_listar(sub_args)
|
||||
when 'hito:listar'
|
||||
comando_hito_listar(sub_args)
|
||||
when 'hito:actualizar'
|
||||
comando_hito_actualizar(sub_args)
|
||||
when 'hito:renombrar'
|
||||
comando_hito_renombrar(sub_args)
|
||||
when 'hito:ordenar'
|
||||
comando_hito_ordenar(sub_args)
|
||||
when 'estadisticas'
|
||||
comando_estadisticas
|
||||
when 'backup'
|
||||
comando_backup(sub_args)
|
||||
when 'archivar:md'
|
||||
comando_archivar_md
|
||||
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
# ─── 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}estadisticas#{Color::RESET} Mostrar estadísticas del sistema"
|
||||
puts " #{Color::CYAN}archivar:md#{Color::RESET} Mueve los archivos .md migrados al archivo histórico"
|
||||
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
|
||||
@@ -0,0 +1,112 @@
|
||||
module ADN
|
||||
module SubcomandoDBBitacora
|
||||
# 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
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
module ADN
|
||||
module SubcomandoDBEvento
|
||||
# Comando: evento:crear [OPCIONES] - Crear nueva entrada cronológica
|
||||
def comando_evento_crear(args)
|
||||
opts = {
|
||||
inicio: nil,
|
||||
descripcion: nil,
|
||||
fin: nil,
|
||||
estado: nil,
|
||||
modo: nil,
|
||||
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:actualizar <ID> [OPCIONES] - Actualizar una entrada existente
|
||||
def comando_evento_actualizar(args)
|
||||
if args.length < 1 || args[0].start_with?('-')
|
||||
puts "#{Color::YELLOW}Uso: adn db evento:actualizar <ID> [OPCIONES]#{Color::RESET}"
|
||||
puts "Opciones soportadas: --fin \"HH:MM\", --inicio \"HH:MM\", --estado \"✅\", --descripcion \"Texto\""
|
||||
return
|
||||
end
|
||||
|
||||
id_evento = args.shift.to_i
|
||||
opts = {}
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Opciones:"
|
||||
opt.on("--inicio HH:MM", "Nueva hora de inicio") { |i| opts[:inicio] = i }
|
||||
opt.on("--fin HH:MM", "Nueva hora de fin") { |f| opts[:fin] = f }
|
||||
opt.on("--descripcion TEXTO", "Nueva descripción") { |d| opts[:descripcion] = d }
|
||||
opt.on("--estado ESTADO", "Nuevo estado (⏳, ✅, ❌, ⚠️, ⏸️)") { |e| opts[:estado] = e }
|
||||
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}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
if opts.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --fin, --inicio, --descripcion o --estado#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando evento #{id_evento}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
res = db.update_entrada(id_evento, opts)
|
||||
if res
|
||||
puts "#{Color::GREEN}✅ Evento #{id_evento} actualizado exitosamente.#{Color::RESET}"
|
||||
puts " #{Color::BOLD}Inicio:#{Color::RESET} #{res['inicio']}"
|
||||
puts " #{Color::BOLD}Fin:#{Color::RESET} #{res['fin'] || '(sin fin)'}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ No se pudo actualizar el evento. ¿Existe el ID #{id_evento}?#{Color::RESET}"
|
||||
end
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: evento:listar [OPCIONES] - Listar entradas con filtros
|
||||
def comando_evento_listar(args)
|
||||
opts = { limit: 20, offset: 0, nodo: nil, estado: nil, buscar: nil, desde: nil, hasta: 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("--buscar TEXTO", "Buscar texto en descripción") do |b|
|
||||
opts[:buscar] = b
|
||||
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 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],
|
||||
buscar: opts[:buscar],
|
||||
desde: opts[:desde],
|
||||
hasta: opts[:hasta],
|
||||
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
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,214 @@
|
||||
module ADN
|
||||
module SubcomandoDBHito
|
||||
# Comando: hito:listar [PROYECTO]
|
||||
def comando_hito_listar(args)
|
||||
if args.empty?
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:listar <CODIGO_PROYECTO> (ej: P2605)#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
codigo = args[0]
|
||||
puts "#{Color::CYAN}📋 Listando hitos del proyecto #{codigo.upcase}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
hitos = db.list_hitos(codigo)
|
||||
if hitos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron hitos o el proyecto no existe.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"Hito".ljust(8)} #{"Fase".ljust(6)} #{"Estado".ljust(8)} #{"Hrs P/R".ljust(10)} #{"Título"}#{Color::RESET}"
|
||||
puts "#{"─" * 8} #{"─" * 6} #{"─" * 8} #{"─" * 10} #{"─" * 40}"
|
||||
|
||||
hitos.each do |h|
|
||||
hrs = "#{h['horas_presencial']}/#{h['horas_remoto']}"
|
||||
puts "#{h['id_hito'].ljust(8)} #{h['fase_num'].to_s.ljust(6)} #{h['estado'].ljust(8)} #{hrs.ljust(10)} #{h['titulo']}"
|
||||
end
|
||||
puts "\nTotal: #{hitos.size} hitos"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: hito:ordenar <PROYECTO> [--aplicar FILE]
|
||||
def comando_hito_ordenar(args)
|
||||
codigo = args.shift
|
||||
if !codigo
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:ordenar <PROYECTO> [--aplicar /ruta/al/archivo.txt]#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
aplicar_path = nil
|
||||
if args[0] == '--aplicar' && args[1]
|
||||
aplicar_path = args[1]
|
||||
end
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
hitos = db.list_hitos(codigo)
|
||||
if hitos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron hitos para #{codigo.upcase}.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
if aplicar_path
|
||||
unless File.exist?(aplicar_path)
|
||||
puts "#{Color::RED}✗ No se encontró el archivo: #{aplicar_path}#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
# Procesar el archivo y aplicarrenombramientos lógicos "Fase.Grupo.Paso"
|
||||
puts "#{Color::CYAN}📋 Aplicando ordenamiento desde #{aplicar_path}...#{Color::RESET}"
|
||||
lineas = File.readlines(aplicar_path).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
|
||||
|
||||
# Agrupar por fase según el orden de las líneas
|
||||
hitos_por_fase = Hash.new { |h, k| h[k] = [] }
|
||||
lineas.each do |linea|
|
||||
if linea =~ /^(\d+):\s*\[.+?\]\s*(.+)$/
|
||||
id_db = $1.to_i
|
||||
hito = hitos.find { |h| h['id'] == id_db }
|
||||
hitos_por_fase[hito['fase_num']] << hito if hito
|
||||
end
|
||||
end
|
||||
|
||||
# Iniciar renombrado secuencial
|
||||
hitos_por_fase.each do |fase, hitos_fase|
|
||||
grupo = 1
|
||||
letra = 65 # 'A'
|
||||
|
||||
hitos_fase.each do |h|
|
||||
nuevo_id = "#{fase}.#{grupo}.#{letra.chr}"
|
||||
|
||||
if h['id_hito'] != nuevo_id
|
||||
db.rename_hito_id(codigo, h['id_hito'], nuevo_id)
|
||||
puts " #{Color::GREEN}✔ #{h['id_hito'].ljust(8)} → #{nuevo_id.ljust(8)}#{Color::RESET} | #{h['titulo']}"
|
||||
else
|
||||
puts " #{Color::DIM}• #{nuevo_id.ljust(8)} (Sin cambios) | #{h['titulo']}#{Color::RESET}"
|
||||
end
|
||||
|
||||
letra += 1
|
||||
if letra > 67 # 'C'
|
||||
letra = 65
|
||||
grupo += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
puts "#{Color::GREEN}✅ Ordenamiento aplicado exitosamente a #{codigo.upcase}.#{Color::RESET}"
|
||||
|
||||
else
|
||||
# Generar archivo temporal para reordenamiento (Swarm-Ready)
|
||||
tmp_file = "/tmp/adn_ordenar_#{codigo.downcase}.txt"
|
||||
File.open(tmp_file, 'w') do |f|
|
||||
f.puts "# ==========================================================================="
|
||||
f.puts "# MODO ORDENAMIENTO DE HITOS - PROYECTO #{codigo.upcase}"
|
||||
f.puts "# ==========================================================================="
|
||||
f.puts "# Cambia el orden de las líneas (Cortar y Pegar) dentro de la misma FASE"
|
||||
f.puts "# para definir la secuencia lógica de ejecución."
|
||||
f.puts "# NO MODIFIQUES el número inicial ni los corchetes (ej: '42: [F3]')."
|
||||
f.puts "# Cuando termines de editar, guarda el archivo y ejecuta en la terminal:"
|
||||
f.puts "# adn db hito:ordenar #{codigo.upcase} --aplicar #{tmp_file}"
|
||||
f.puts "# ===========================================================================\n\n"
|
||||
|
||||
fases = hitos.group_by { |h| h['fase_num'] }.sort_by { |k, v| k }
|
||||
fases.each do |num_fase, hitos_fase|
|
||||
f.puts "# --- FASE #{num_fase} ---"
|
||||
hitos_fase.each do |h|
|
||||
f.puts "#{h['id']}: [F#{num_fase}] #{h['titulo']}"
|
||||
end
|
||||
f.puts "\n"
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}✅ Archivo de configuración generado en: #{tmp_file}#{Color::RESET}"
|
||||
puts "1. Abre el archivo y reordena las líneas como necesites."
|
||||
puts "2. Ejecuta: #{Color::BOLD}adn db hito:ordenar #{codigo.upcase} --aplicar #{tmp_file}#{Color::RESET}"
|
||||
end
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: hito:renombrar <PROYECTO> <ID_HITO_ACTUAL> <NUEVO_ID_HITO>
|
||||
def comando_hito_renombrar(args)
|
||||
if args.length < 3
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:renombrar <PROYECTO> <ID_HITO_ACTUAL> <NUEVO_ID_HITO>#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
proyecto = args[0]
|
||||
id_actual = args[1]
|
||||
id_nuevo = args[2]
|
||||
|
||||
puts "#{Color::CYAN}🔄 Renombrando hito #{id_actual} → #{id_nuevo} (Proyecto #{proyecto.upcase})...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
res = db.rename_hito_id(proyecto, id_actual, id_nuevo)
|
||||
puts "#{Color::GREEN}✅ Hito renombrado exitosamente. Título: #{res['titulo']}#{Color::RESET}"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: hito:actualizar <PROYECTO> <ID_HITO> [OPCIONES]
|
||||
def comando_hito_actualizar(args)
|
||||
if args.length < 2
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:actualizar <PROYECTO> <ID_HITO> [OPCIONES]#{Color::RESET}"
|
||||
puts "Opciones soportadas: --estado=EJ, --horas-p=1.5, --horas-r=0, --desc=\"Texto\""
|
||||
return
|
||||
end
|
||||
|
||||
proyecto = args.shift
|
||||
id_hito = args.shift
|
||||
opts = { estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Opciones:"
|
||||
opt.on("--estado ESTADO", "Nuevo estado (ej: ✅, ⏳, 📋)") { |e| opts[:estado] = e }
|
||||
opt.on("--horas-p HORAS", "Horas presenciales") { |h| opts[:horas_presencial] = h.to_f }
|
||||
opt.on("--horas-r HORAS", "Horas remotas") { |h| opts[:horas_remoto] = h.to_f }
|
||||
opt.on("--desc DESC", "Descripción o detalle para el hito") { |d| opts[:descripcion] = d }
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
if opts.values.all?(&:nil?)
|
||||
puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --estado, --horas-p, --horas-r o --desc#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando hito #{id_hito} del proyecto #{proyecto.upcase}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
res = db.update_hito(
|
||||
proyecto, id_hito,
|
||||
estado: opts[:estado],
|
||||
horas_presencial: opts[:horas_presencial],
|
||||
horas_remoto: opts[:horas_remoto],
|
||||
descripcion: opts[:descripcion]
|
||||
)
|
||||
puts "#{Color::GREEN}✅ Hito actualizado exitosamente.#{Color::RESET}"
|
||||
hrs = "#{res['horas_presencial'].to_f}P / #{res['horas_remoto'].to_f}R"
|
||||
puts " Estado actual : #{res['estado']}"
|
||||
puts " Horas imputadas : #{hrs}"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,247 @@
|
||||
module ADN
|
||||
module SubcomandoDBMisc
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
# Comando: archivar:md - Mover bitácoras y proyectos MD importados/obsoletos a carpetas _hist
|
||||
def comando_archivar_md
|
||||
require 'fileutils'
|
||||
puts "#{Color::CYAN}🧹 Iniciando limpieza y archivado de archivos Markdown...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
bitacoras_dir = defined?(ADN::BITACORAS_DIR) ? ADN::BITACORAS_DIR : File.expand_path('../../../docs/bitacoras', __dir__)
|
||||
proyectos_dir = File.expand_path('../../../docs/proyectos', __dir__)
|
||||
|
||||
hist_bitacoras = File.join(bitacoras_dir, '_hist')
|
||||
hist_proyectos = File.join(proyectos_dir, '_hist')
|
||||
|
||||
FileUtils.mkdir_p(hist_bitacoras)
|
||||
FileUtils.mkdir_p(hist_proyectos)
|
||||
|
||||
# 1. Archivar Bitácoras Diarias
|
||||
puts "\\n#{Color::BOLD}>> Verificando Bitácoras Diarias...#{Color::RESET}"
|
||||
bitacoras_db = db.send(:execute, "SELECT fecha FROM bitacoras.bitacoras").map { |row| row['fecha'] }
|
||||
|
||||
Dir.glob(File.join(bitacoras_dir, '*.md')).each do |md_path|
|
||||
next if File.basename(md_path) == 'README.md'
|
||||
fecha_md = File.basename(md_path, '.md')
|
||||
|
||||
if bitacoras_db.include?(fecha_md)
|
||||
dest = File.join(hist_bitacoras, File.basename(md_path))
|
||||
FileUtils.mv(md_path, dest)
|
||||
puts " #{Color::GREEN}✓ Archivada:#{Color::RESET} #{fecha_md}.md"
|
||||
else
|
||||
puts " #{Color::YELLOW}⚠ No en DB (se omite):#{Color::RESET} #{fecha_md}.md"
|
||||
end
|
||||
end
|
||||
|
||||
# 2. Archivar Proyectos
|
||||
puts "\\n#{Color::BOLD}>> Verificando Proyectos MD...#{Color::RESET}"
|
||||
proyectos_db = db.send(:execute, "SELECT codigo FROM bitacoras.proyectos").map { |row| row['codigo'] }
|
||||
|
||||
Dir.glob(File.join(proyectos_dir, '*.md')).each do |md_path|
|
||||
next if File.basename(md_path) == 'README.md'
|
||||
# Extraer el código PXXXX del principio del archivo, asumiendo formato PXXXX_Titulo.md o PXXXX.md
|
||||
codigo_match = File.basename(md_path).match(/^(P\d+)/)
|
||||
|
||||
if codigo_match && proyectos_db.include?(codigo_match[1])
|
||||
dest = File.join(hist_proyectos, File.basename(md_path))
|
||||
FileUtils.mv(md_path, dest)
|
||||
puts " #{Color::GREEN}✓ Archivado Proyecto:#{Color::RESET} #{File.basename(md_path)}"
|
||||
else
|
||||
puts " #{Color::YELLOW}⚠ Proyecto no registrado en DB o formato no válido:#{Color::RESET} #{File.basename(md_path)}"
|
||||
end
|
||||
end
|
||||
|
||||
puts "\\n#{Color::GREEN}✅ Limpieza Markdown finalizada. Entorno 100% DB-First fortalecido.#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
module ADN
|
||||
module SubcomandoDBNodo
|
||||
# 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
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,230 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'date'
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoGenerar
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
# Envolvemos todo en un manejador de eventos del sistema
|
||||
ADN.eventos.publicar(:sistema_iniciado, { subcomando: 'generar' }, silencioso: true)
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere tipo de generación")
|
||||
mostrar_ayuda_generar
|
||||
exit 1
|
||||
end
|
||||
|
||||
tipo = @args[0]
|
||||
@args.shift
|
||||
|
||||
case tipo
|
||||
when 'bitacora'
|
||||
generar_bitacora
|
||||
when 'nodo'
|
||||
generar_nodo
|
||||
when 'proyecto'
|
||||
generar_proyecto
|
||||
else
|
||||
@logger.error("Tipo de generación desconocido: #{tipo}")
|
||||
mostrar_ayuda_generar
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda_generar
|
||||
puts "Uso: ./tools/adn/run generar <tipo> [argumentos]"
|
||||
puts ""
|
||||
puts "Tipos disponibles:"
|
||||
puts " bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD, default: hoy)"
|
||||
puts " nodo <nombre> Generar ficha de nodo"
|
||||
puts " proyecto <código> Generar manifiesto de proyecto"
|
||||
end
|
||||
|
||||
def generar_bitacora
|
||||
fecha = @args[0] || Date.today.iso8601
|
||||
|
||||
# Validar formato de fecha
|
||||
unless fecha.match?(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@logger.error("Formato de fecha inválido. Usar YYYY-MM-DD")
|
||||
exit 1
|
||||
end
|
||||
|
||||
ruta_bitacora = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
|
||||
|
||||
if File.exist?(ruta_bitacora)
|
||||
@logger.advertencia("La bitácora para #{fecha} ya existe")
|
||||
puts "#{Color::YELLOW}⚠ La bitácora #{fecha}.md ya existe#{Color::RESET}"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# Plantilla de bitácora
|
||||
plantilla = <<~PLANTILLA_BITACORA
|
||||
# Bitácora #{fecha}
|
||||
|
||||
---
|
||||
|
||||
## 📋 Control de Gestión
|
||||
|
||||
### Pendientes
|
||||
|
||||
| ID | Nodo | Detalle |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
### En Proceso
|
||||
|
||||
| ID | Nodo | Detalle |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
### Resumen de Actividades
|
||||
|
||||
| Nodo | Resumen Integral |
|
||||
| :--- | :--- |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Actividades Detalladas
|
||||
|
||||
<!-- Agregar actividades por nodo aquí -->
|
||||
|
||||
---
|
||||
|
||||
<!-- 🤖 PREMISAS DE TRABAJO (IA): Fuente de Verdad → adn/05_ia.md -->
|
||||
PLANTILLA_BITACORA
|
||||
|
||||
File.write(ruta_bitacora, plantilla)
|
||||
|
||||
@logger.exito("Bitácora creada: #{fecha}.md")
|
||||
puts "#{Color::GREEN}✓ Bitácora creada en: #{ruta_bitacora}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Para editarla: vim #{ruta_bitacora}#{Color::RESET}"
|
||||
end
|
||||
|
||||
def generar_nodo
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere nombre para el nodo")
|
||||
puts "Uso: ./tools/adn/run generar nodo <nombre-nodo>"
|
||||
exit 1
|
||||
end
|
||||
|
||||
nombre = @args[0].downcase # Forzar minúsculas según gobernanza
|
||||
|
||||
ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre}.md")
|
||||
|
||||
if File.exist?(ruta_nodo)
|
||||
@logger.advertencia("La ficha para el nodo #{nombre} ya existe")
|
||||
puts "#{Color::YELLOW}⚠ La ficha #{nombre}.md ya existe#{Color::RESET}"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# Plantilla de nodo
|
||||
plantilla = <<~PLANTILLA_NODO
|
||||
# Ficha de Nodo: #{nombre}
|
||||
|
||||
| Atributo | Valor |
|
||||
| :--- | :--- |
|
||||
| **Hostname** | `#{nombre}` |
|
||||
| **IP LAN** | `10.0.10.X` |
|
||||
| **Sistema Operativo** | *Especificar* |
|
||||
| **Rol** | *Describir función* |
|
||||
|
||||
## Especificaciones de Hardware
|
||||
- **CPU**: *Especificar*
|
||||
- **RAM**: *Especificar*
|
||||
- **Disco**: *Especificar*
|
||||
|
||||
## Servicios y Responsabilidades
|
||||
1. *Listar servicios críticos*
|
||||
|
||||
## Acceso y Mantenimiento
|
||||
- **Gestión**: *SSH, Web, etc.*
|
||||
- **Backup**: *Estrategia de respaldo*
|
||||
- **Monitorización**: *Herramientas de monitoreo*
|
||||
|
||||
## Dependencias
|
||||
- *Listar nodos/proyectos dependientes*
|
||||
|
||||
## Historial de Cambios Relevantes
|
||||
- #{Date.today}: Ficha creada automáticamente
|
||||
PLANTILLA_NODO
|
||||
|
||||
File.write(ruta_nodo, plantilla)
|
||||
|
||||
@logger.exito("Ficha de nodo creada: #{nombre}.md")
|
||||
puts "#{Color::GREEN}✓ Ficha de nodo creada en: #{ruta_nodo}#{Color::RESET}"
|
||||
end
|
||||
|
||||
def generar_proyecto
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere código para el proyecto")
|
||||
puts "Uso: ./tools/adn/run generar proyecto <código> [nombre]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
codigo = @args[0].upcase
|
||||
nombre = @args[1] || "Proyecto #{codigo}"
|
||||
|
||||
ruta_proyecto = File.join(ADN::PROYECTOS_DIR, "#{codigo}_#{nombre.downcase.gsub(/\s+/, '_')}.md")
|
||||
|
||||
if File.exist?(ruta_proyecto)
|
||||
@logger.advertencia("El manifiesto para el proyecto #{codigo} ya existe")
|
||||
puts "#{Color::YELLOW}⚠ El manifiesto #{codigo}_*.md ya existe#{Color::RESET}"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# Plantilla de proyecto
|
||||
plantilla = <<~PLANTILLA_PROYECTO
|
||||
# #{codigo} - #{nombre}
|
||||
|
||||
**Estado:** 📍 Definido
|
||||
**Fecha Inicio:** #{Date.today}
|
||||
**Fecha Fin Estimada:** *Especificar*
|
||||
|
||||
## Objetivo
|
||||
*Describir el propósito y qué problema resuelve*
|
||||
|
||||
## Stakeholders
|
||||
| Rol | Nombre | Contacto |
|
||||
| :--- | :--- | :--- |
|
||||
| *Ej: Coordinador* | *Nombre* | *email/tel* |
|
||||
|
||||
## 🌐 Topología y Contexto de Red
|
||||
*Describir ubicación de nodos, subredes, requisitos de acceso*
|
||||
|
||||
## Nodos Involucrados
|
||||
### Nodos Exclusivos (Core)
|
||||
- *Listar nodos centrales del proyecto*
|
||||
|
||||
### Nodos de Soporte/Infraestructura
|
||||
- *Listar nodos de infraestructura compartida*
|
||||
|
||||
## Herramientas
|
||||
- *Software, scripts y servicios utilizados*
|
||||
|
||||
## Hitos Clave
|
||||
| ID | Hito | Estado | Fecha |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| 1 | *Primer hito* | 📍 | *Fecha* |
|
||||
|
||||
## Dashboard
|
||||
*URL del panel de seguimiento web (si aplica)*
|
||||
|
||||
## Referencias
|
||||
- [ADN del Proyecto](../adn/00_indice.md)
|
||||
- *Otras referencias relevantes*
|
||||
|
||||
---
|
||||
|
||||
*Manifiesto generado automáticamente el #{Date.today}*
|
||||
PLANTILLA_PROYECTO
|
||||
|
||||
File.write(ruta_proyecto, plantilla)
|
||||
|
||||
@logger.exito("Manifiesto de proyecto creado: #{codigo}")
|
||||
puts "#{Color::GREEN}✓ Manifiesto creado en: #{ruta_proyecto}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# tools/adn/cli/inicio.rb — Subcomando para inicio de jornada laboral (DB-First)
|
||||
# ==============================================================================
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
module ADN
|
||||
class SubcomandoInicio
|
||||
require_relative '../core/colores'
|
||||
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@fecha = Date.today
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || !(@args.include?('jornada'))
|
||||
puts "#{Color::YELLOW}Uso: ./tools/adn/run inicio de jornada [presencial|remoto]#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
modo = @args.include?('presencial') ? 'P' : 'R'
|
||||
tipo_str = modo == 'P' ? 'Presencial' : 'Remoto'
|
||||
hora_ini = Time.now.strftime('%H:%M')
|
||||
|
||||
@logger.info("Iniciando jornada #{tipo_str} (Modo DB-First)")
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# 1. Asegurar que existe la bitácora
|
||||
bitacora = db.find_bitacora_by_fecha(@fecha)
|
||||
unless bitacora
|
||||
@logger.info("Creando bitácora en DB para hoy...")
|
||||
bitacora = db.create_bitacora(fecha: @fecha)
|
||||
end
|
||||
|
||||
# 2. Obtener nodo srv-ns8
|
||||
nodo = db.find_nodo_by_nombre('srv-ns8')
|
||||
unless nodo
|
||||
@logger.warn("Nodo 'srv-ns8' no encontrado, usando nodo general...")
|
||||
# Podríamos crearlo o buscar uno por defecto
|
||||
end
|
||||
|
||||
# 3. Crear entrada de inicio
|
||||
db.create_entrada(
|
||||
inicio: hora_ini,
|
||||
descripcion: "Inicio de jornada #{tipo_str.downcase}. Sincronización de contexto y revisión de tareas.",
|
||||
estado: '👁️',
|
||||
modo: modo,
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo ? nodo['id'] : nil
|
||||
)
|
||||
|
||||
@logger.exito("Entrada de inicio creada en Base de Datos.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,507 @@
|
||||
# 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 '../parsers/plan'
|
||||
rescue LoadError => e
|
||||
puts "⚠ Advertencia: No se pudo cargar PlanParser: #{e.message}"
|
||||
puts " Asegúrate de que tools/adn/parsers/plan.rb existe"
|
||||
end
|
||||
|
||||
module ADN
|
||||
class SubcomandoPlan
|
||||
require_relative '../core/colores'
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,121 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require 'json'
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoSalud
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = { dashboard: false, json: false }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./tools/adn/run salud [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Muestra salud general del sistema ADN"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--dashboard", "Mostrar en formato dashboard (no implementado)") do
|
||||
@opciones[:dashboard] = true
|
||||
end
|
||||
opts.on("--json", "Salida en formato JSON") do
|
||||
@opciones[:json] = true
|
||||
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
|
||||
|
||||
mostrar_salud
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_salud
|
||||
@logger.info("Generando reporte de salud del ADN")
|
||||
|
||||
datos = {
|
||||
timestamp: Time.now.utc.iso8601,
|
||||
hebras_adn: contar_hebras_adn,
|
||||
bitacoras: contar_bitacoras,
|
||||
nodos: contar_nodos,
|
||||
proyectos: contar_proyectos,
|
||||
errores_recientes: buscar_errores_recientes
|
||||
}
|
||||
|
||||
if @opciones[:json]
|
||||
puts JSON.pretty_generate(datos)
|
||||
return
|
||||
end
|
||||
|
||||
if @opciones[:dashboard]
|
||||
puts "#{Color::YELLOW}⚠ El dashboard será implementado en Fase 3#{Color::RESET}"
|
||||
# TODO: Implementar dashboard en Fase 3
|
||||
end
|
||||
|
||||
# Mostrar en formato legible
|
||||
puts "#{Color::CYAN}🧬 Salud del Sistema ADN#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Hebras ADN:#{Color::RESET} #{datos[:hebras_adn]} / 7"
|
||||
puts " #{Color::GREEN}Bitácoras:#{Color::RESET} #{datos[:bitacoras]}"
|
||||
puts " #{Color::GREEN}Nodos registrados:#{Color::RESET} #{datos[:nodos]}"
|
||||
puts " #{Color::GREEN}Proyectos activos:#{Color::RESET} #{datos[:proyectos]}"
|
||||
|
||||
if datos[:errores_recientes] > 0
|
||||
puts " #{Color::RED}Errores recientes:#{Color::RESET} #{datos[:errores_recientes]}"
|
||||
else
|
||||
puts " #{Color::GREEN}Errores recientes:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Para más detalles: ./tools/adn/run salud --json#{Color::RESET}"
|
||||
|
||||
@logger.exito("Reporte de salud generado")
|
||||
end
|
||||
|
||||
def contar_hebras_adn
|
||||
Dir.glob(File.join(ADN::PROJECT_ROOT, 'adn', "*.md")).count
|
||||
end
|
||||
|
||||
def contar_bitacoras
|
||||
Dir.glob(File.join(ADN::BITACORAS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def contar_nodos
|
||||
Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def contar_proyectos
|
||||
Dir.glob(File.join(ADN::PROYECTOS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def buscar_errores_recientes
|
||||
# Buscar en logs de hoy
|
||||
log_hoy = File.join(ADN::LOGS_DIR, "#{Date.today}.log")
|
||||
return 0 unless File.exist?(log_hoy)
|
||||
|
||||
errores = 0
|
||||
File.foreach(log_hoy) do |linea|
|
||||
begin
|
||||
entrada = JSON.parse(linea)
|
||||
errores += 1 if entrada['nivel'] == 'ERROR'
|
||||
rescue JSON::ParserError
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
errores
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../core/colores'
|
||||
require_relative '../core/validador'
|
||||
|
||||
class SubcomandoValidador
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = { watch: false, fix: false }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./tools/adn/run validador [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Valida el cumplimiento del ADN en las bitácoras"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--watch", "Monitorizar cambios en tiempo real") do
|
||||
@opciones[:watch] = true
|
||||
end
|
||||
opts.on("--fix", "Intentar corregir errores automáticamente") do
|
||||
@opciones[:fix] = true
|
||||
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
|
||||
|
||||
@logger.info("Iniciando validador del ADN", @opciones)
|
||||
|
||||
if @opciones[:watch]
|
||||
@logger.info("Modo watch activado (no implementado aún)")
|
||||
puts "#{Color::YELLOW}⚠ El modo watch será implementado en Fase 1 completa#{Color::RESET}"
|
||||
# TODO: Implementar con gem 'listen'
|
||||
end
|
||||
|
||||
# Inicializar validador
|
||||
validador = ADN::Validador.new(nil, @logger)
|
||||
|
||||
# Ejecutar validación
|
||||
resultados = validador.validar_bitacoras(@opciones)
|
||||
|
||||
# Mostrar resultados
|
||||
mostrar_resultados(resultados, validador)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_resultados(resultados, validador)
|
||||
total = resultados[:total]
|
||||
validas = resultados[:validas]
|
||||
con_errores = resultados[:con_errores]
|
||||
con_advertencias = resultados[:con_advertencias]
|
||||
|
||||
puts "\n#{Color::CYAN}📊 Resultados de validación:#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Bitácoras totales:#{Color::RESET} #{total}"
|
||||
puts " #{Color::GREEN}Válidas:#{Color::RESET} #{validas}"
|
||||
|
||||
if con_errores > 0
|
||||
puts " #{Color::RED}Con errores:#{Color::RESET} #{con_errores}"
|
||||
else
|
||||
puts " #{Color::GREEN}Con errores:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
if con_advertencias > 0
|
||||
puts " #{Color::YELLOW}Con advertencias:#{Color::RESET} #{con_advertencias}"
|
||||
else
|
||||
puts " #{Color::GREEN}Con advertencias:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
# Mostrar detalles de errores y advertencias
|
||||
mostrar_detalles(resultados[:detalles])
|
||||
|
||||
# Resumen final
|
||||
if con_errores == 0 && con_advertencias == 0
|
||||
@logger.exito("Validación completada: #{total} bitácoras OK")
|
||||
puts "#{Color::GREEN}✓ Todas las bitácoras cumplen con el formato del ADN#{Color::RESET}"
|
||||
elsif con_errores == 0
|
||||
@logger.advertencia("Validación completada con advertencias")
|
||||
puts "#{Color::YELLOW}⚠ Validación completada con #{con_advertencias} advertencia(s)#{Color::RESET}"
|
||||
else
|
||||
@logger.error("Validación completada con errores")
|
||||
puts "#{Color::RED}✗ Validación completada con #{con_errores} error(es) y #{con_advertencias} advertencia(s)#{Color::RESET}"
|
||||
end
|
||||
|
||||
# Log de métricas
|
||||
@logger.info("Validación completada", {
|
||||
total_bitacoras: total,
|
||||
validas: validas,
|
||||
con_errores: con_errores,
|
||||
con_advertencias: con_advertencias,
|
||||
correcciones: validador.correcciones
|
||||
})
|
||||
end
|
||||
|
||||
def mostrar_detalles(detalles)
|
||||
detalles.each do |detalle|
|
||||
archivo = detalle[:archivo]
|
||||
errores = detalle[:errores]
|
||||
advertencias = detalle[:advertencias]
|
||||
|
||||
next if errores.empty? && advertencias.empty?
|
||||
|
||||
puts "\n#{Color::DIM}#{archivo}#{Color::RESET}"
|
||||
|
||||
errores.each do |error|
|
||||
puts " #{Color::RED}✗ #{error}#{Color::RESET}"
|
||||
end
|
||||
|
||||
advertencias.each do |advertencia|
|
||||
puts " #{Color::YELLOW}⚠ #{advertencia}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user