diff --git a/tools/adn/comando/db.rb b/tools/adn/comando/db.rb index 96f5f72e..83de6984 100644 --- a/tools/adn/comando/db.rb +++ b/tools/adn/comando/db.rb @@ -37,6 +37,16 @@ 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] Argumentos de línea de comandos @@ -111,958 +121,6 @@ module ADN # ─── COMANDOS PRINCIPALES ────────────────────────────────────────────── - # Comando: salud:bd - Verificar conexión y estado de la base de datos - def comando_salud_bd - puts "#{Color::CYAN}🔍 Verificando salud de la base de datos dtic-BITACORAs...#{Color::RESET}" - puts "" - - begin - BitacorasDB::BitacoraDB.with_connection(env: :development) do |db| - salud = db.health_check - - case salud[:status] - when 'healthy' - puts "#{Color::GREEN}✅ Conexión a PostgreSQL establecida#{Color::RESET}" - puts "" - puts " #{Color::BOLD}Versión PostgreSQL:#{Color::RESET} #{salud[:postgres_version].split(',')[0]}" - puts " #{Color::BOLD}Bitácoras en BD:#{Color::RESET} #{salud[:bitacoras_count]}" - puts " #{Color::BOLD}Servidor:#{Color::RESET} #{salud[:connection][:host]}:#{salud[:connection][:port]}" - puts " #{Color::BOLD}Base de datos:#{Color::RESET} #{salud[:connection][:database]}" - puts " #{Color::BOLD}Timestamp:#{Color::RESET} #{salud[:timestamp]}" - when 'disconnected' - puts "#{Color::RED}❌ No hay conexión activa a la base de datos#{Color::RESET}" - puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}" - when 'unhealthy' - puts "#{Color::YELLOW}⚠ Problema de salud en la base de datos#{Color::RESET}" - puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}" - end - end - - # Verificar contenedor Docker - puts "" - puts "#{Color::CYAN}🔍 Verificando contenedor Docker...#{Color::RESET}" - if system("docker ps --filter name=postgres --format '{{.Names}}' | grep -q postgres") - puts "#{Color::GREEN}✅ Contenedor PostgreSQL en ejecución#{Color::RESET}" - else - puts "#{Color::YELLOW}⚠ Contenedor PostgreSQL no encontrado#{Color::RESET}" - puts " Ejecuta: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}" - end - - rescue PG::ConnectionBad => e - puts "#{Color::RED}❌ Error de conexión a PostgreSQL#{Color::RESET}" - puts " #{e.message}" - puts "" - puts "#{Color::YELLOW}Solución:#{Color::RESET}" - puts " 1. Verifica que el contenedor esté en ejecución:" - puts " #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}" - puts " 2. Espera 10-20 segundos para que PostgreSQL inicie" - puts " 3. Verifica credenciales en tools/config/database.yml" - rescue => e - puts "#{Color::RED}❌ Error inesperado#{Color::RESET}" - puts " #{e.message}" - end - end - - # Comando: bitacora:crear [FECHA] - Crear nueva bitácora diaria - def comando_bitacora_crear(args) - opts = { fecha: Date.today, resumen: nil } - - parser = OptionParser.new do |opt| - opt.banner = "Uso: adn db bitacora:crear [FECHA] [OPCIONES]" - opt.on("--resumen TEXTO", "Resumen inicial de la bitácora") do |r| - opts[:resumen] = r - end - opt.on("-h", "--help", "Mostrar ayuda") do - puts opt - exit - end - end - - begin - parser.parse!(args) - # Si queda un argumento sin procesar, asumimos que es la fecha - opts[:fecha] = Date.parse(args[0]) if args[0] - rescue OptionParser::InvalidOption, Date::Error => e - puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" - puts parser - exit 1 - end - - puts "#{Color::CYAN}📝 Creando bitácora para #{opts[:fecha]}...#{Color::RESET}" - - BitacorasDB::BitacoraDB.with_connection do |db| - begin - bitacora = db.create_bitacora(fecha: opts[:fecha], resumen: opts[:resumen]) - puts "#{Color::GREEN}✅ Bitácora creada exitosamente#{Color::RESET}" - puts "" - puts " #{Color::BOLD}ID:#{Color::RESET} #{bitacora['id']}" - puts " #{Color::BOLD}Fecha:#{Color::RESET} #{bitacora['fecha']}" - puts " #{Color::BOLD}Resumen:#{Color::RESET} #{bitacora['resumen'] || '(sin resumen)'}" - puts " #{Color::BOLD}Creada:#{Color::RESET} #{bitacora['created_at']}" - rescue PG::UniqueViolation - puts "#{Color::YELLOW}⚠ Ya existe una bitácora para la fecha #{opts[:fecha]}#{Color::RESET}" - puts " Usa #{Color::DIM}adn db bitacora:listar#{Color::RESET} para ver bitácoras existentes" - end - end - end - - # Comando: bitacora:listar [OPCIONES] - Listar bitácoras existentes - def comando_bitacora_listar(args) - opts = { limit: 10, offset: 0, desde: nil, hasta: nil } - - parser = OptionParser.new do |opt| - opt.banner = "Uso: adn db bitacora:listar [OPCIONES]" - opt.on("--limit N", Integer, "Número máximo de resultados (default: 10)") do |n| - opts[:limit] = n - end - opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n| - opts[:offset] = n - end - opt.on("--desde FECHA", "Filtrar desde fecha (YYYY-MM-DD)") do |d| - opts[:desde] = d - end - opt.on("--hasta FECHA", "Filtrar hasta fecha (YYYY-MM-DD)") do |h| - opts[:hasta] = h - end - opt.on("-h", "--help", "Mostrar ayuda") do - puts opt - exit - end - end - - begin - parser.parse!(args) - rescue OptionParser::InvalidOption => e - puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" - puts parser - exit 1 - end - - puts "#{Color::CYAN}📋 Listando bitácoras...#{Color::RESET}" - - BitacorasDB::BitacoraDB.with_connection do |db| - bitacoras = db.list_bitacoras( - limit: opts[:limit], - offset: opts[:offset], - desde: opts[:desde], - hasta: opts[:hasta] - ) - - if bitacoras.empty? - puts "#{Color::YELLOW}⚠ No se encontraron bitácoras#{Color::RESET}" - return - end - - puts "" - puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Fecha".ljust(12)} #{"Resumen".ljust(40)} #{"Creada".ljust(20)}#{Color::RESET}" - puts "#{"─" * 5} #{"─" * 12} #{"─" * 40} #{"─" * 20}" - - bitacoras.each do |b| - fecha = b['fecha'] - resumen = b['resumen'] || '' - resumen = resumen[0..37] + '...' if resumen.length > 40 - creada = Time.parse(b['created_at']).strftime('%Y-%m-%d %H:%M') - - puts "#{b['id'].to_s.ljust(5)} #{fecha.ljust(12)} #{resumen.ljust(40)} #{creada.ljust(20)}" - end - - puts "" - puts "Total: #{bitacoras.length} bitácoras" - end - end - - # Comando: evento:crear [OPCIONES] - Crear nueva entrada cronológica - def comando_evento_crear(args) - opts = { - inicio: nil, - descripcion: nil, - fin: nil, - estado: 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 [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 [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 - - # Comando: nodo:crear [OPCIONES] - Crear nuevo nodo - def comando_nodo_crear(args) - opts = { nombre: nil, ip: nil, tipo: 'servidor', descripcion: nil, activo: true } - - parser = OptionParser.new do |opt| - opt.banner = "Uso: adn db nodo:crear [OPCIONES]" - opt.on("--nombre NOMBRE", "Nombre único del nodo (requerido)") do |n| - opts[:nombre] = n - end - opt.on("--ip DIRECCION", "Dirección IP (opcional)") do |i| - opts[:ip] = i - end - opt.on("--tipo TIPO", "Tipo (servidor, hipervisor, vm, pc)") do |t| - opts[:tipo] = t - end - opt.on("--descripcion TEXTO", "Descripción del nodo") do |d| - opts[:descripcion] = d - end - opt.on("--inactivo", "Crear nodo como inactivo") do - opts[:activo] = false - end - opt.on("-h", "--help", "Mostrar ayuda") do - puts opt - exit - end - end - - begin - parser.parse!(args) - rescue OptionParser::InvalidOption => e - puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" - puts parser - exit 1 - end - - unless opts[:nombre] - puts "#{Color::RED}✗ Error: --nombre es requerido#{Color::RESET}" - puts parser - exit 1 - end - - puts "#{Color::CYAN}🖥️ Creando nodo '#{opts[:nombre]}'...#{Color::RESET}" - - BitacorasDB::BitacoraDB.with_connection do |db| - begin - nodo = db.create_nodo( - nombre: opts[:nombre], - ip: opts[:ip], - tipo: opts[:tipo], - descripcion: opts[:descripcion], - activo: opts[:activo] - ) - - puts "#{Color::GREEN}✅ Nodo creado exitosamente#{Color::RESET}" - puts "" - puts " #{Color::BOLD}ID:#{Color::RESET} #{nodo['id']}" - puts " #{Color::BOLD}Nombre:#{Color::RESET} #{nodo['nombre']}" - puts " #{Color::BOLD}IP:#{Color::RESET} #{nodo['ip'] || '(sin IP)'}" - puts " #{Color::BOLD}Tipo:#{Color::RESET} #{nodo['tipo']}" - puts " #{Color::BOLD}Activo:#{Color::RESET} #{nodo['activo'] ? 'Sí' : 'No'}" - puts " #{Color::BOLD}Descripción:#{Color::RESET} #{nodo['descripcion'] || '(sin descripción)'}" - puts " #{Color::BOLD}Creado:#{Color::RESET} #{nodo['created_at']}" - rescue PG::UniqueViolation - puts "#{Color::YELLOW}⚠ Ya existe un nodo con el nombre '#{opts[:nombre]}'#{Color::RESET}" - puts " Usa #{Color::DIM}adn db nodo:listar#{Color::RESET} para ver nodos existentes" - rescue => e - puts "#{Color::RED}✗ Error creando nodo: #{e.message}#{Color::RESET}" - end - end - end - - # Comando: nodo:listar [OPCIONES] - Listar nodos del sistema - def comando_nodo_listar(args) - opts = { activo: nil, tipo: nil } - - parser = OptionParser.new do |opt| - opt.banner = "Uso: adn db nodo:listar [OPCIONES]" - opt.on("--activo", "Mostrar solo nodos activos") do - opts[:activo] = true - end - opt.on("--inactivo", "Mostrar solo nodos inactivos") do - opts[:activo] = false - end - opt.on("--tipo TIPO", "Filtrar por tipo (servidor, hipervisor, vm, pc)") do |t| - opts[:tipo] = t - end - opt.on("-h", "--help", "Mostrar ayuda") do - puts opt - exit - end - end - - begin - parser.parse!(args) - rescue OptionParser::InvalidOption => e - puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" - puts parser - exit 1 - end - - puts "#{Color::CYAN}🖥️ Listando nodos...#{Color::RESET}" - - BitacorasDB::BitacoraDB.with_connection do |db| - nodos = db.list_nodos(activo: opts[:activo], tipo: opts[:tipo], limit: 100) - - if nodos.empty? - puts "#{Color::YELLOW}⚠ No se encontraron nodos#{Color::RESET}" - return - end - - puts "" - puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"IP".ljust(15)} #{"Tipo".ljust(12)} #{"Activo".ljust(6)} #{"Descripción".ljust(30)}#{Color::RESET}" - puts "#{"─" * 5} #{"─" * 20} #{"─" * 15} #{"─" * 12} #{"─" * 6} #{"─" * 30}" - - nodos.each do |n| - id = n['id'].to_s - nombre = n['nombre'] - ip = n['ip'] || '(sin IP)' - tipo = n['tipo'] - activo = n['activo'] ? '✅' : '❌' - desc = n['descripcion'] || '' - desc = desc[0..27] + '...' if desc.length > 30 - - puts "#{id.ljust(5)} #{nombre.ljust(20)} #{ip.ljust(15)} #{tipo.ljust(12)} #{activo.ljust(6)} #{desc.ljust(30)}" - end - - puts "" - puts "Total: #{nodos.length} nodos" - - # Estadísticas por tipo - tipos = nodos.group_by { |n| n['tipo'] } - puts "#{Color::DIM}Tipos: #{tipos.map { |t, ns| "#{t}: #{ns.length}" }.join(', ')}#{Color::RESET}" - end - end - - # Comando: hito:listar [PROYECTO] - def comando_hito_listar(args) - if args.empty? - puts "#{Color::YELLOW}Uso: adn db hito:listar (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 [--aplicar FILE] - def comando_hito_ordenar(args) - codigo = args.shift - if !codigo - puts "#{Color::YELLOW}Uso: adn db hito:ordenar [--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 - def comando_hito_renombrar(args) - if args.length < 3 - puts "#{Color::YELLOW}Uso: adn db hito:renombrar #{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 [OPCIONES] - def comando_hito_actualizar(args) - if args.length < 2 - puts "#{Color::YELLOW}Uso: adn db hito:actualizar [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 - - - - 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 - # ─── MÉTODOS AUXILIARES ────────────────────────────────────────────── # Carga la configuración desde config.yml diff --git a/tools/adn/comando/db/bitacora.rb b/tools/adn/comando/db/bitacora.rb new file mode 100644 index 00000000..af7512ed --- /dev/null +++ b/tools/adn/comando/db/bitacora.rb @@ -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 diff --git a/tools/adn/comando/db/evento.rb b/tools/adn/comando/db/evento.rb new file mode 100644 index 00000000..a40d6abc --- /dev/null +++ b/tools/adn/comando/db/evento.rb @@ -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 [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 [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 diff --git a/tools/adn/comando/db/hito.rb b/tools/adn/comando/db/hito.rb new file mode 100644 index 00000000..abc65256 --- /dev/null +++ b/tools/adn/comando/db/hito.rb @@ -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 (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 [--aplicar FILE] + def comando_hito_ordenar(args) + codigo = args.shift + if !codigo + puts "#{Color::YELLOW}Uso: adn db hito:ordenar [--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 + def comando_hito_renombrar(args) + if args.length < 3 + puts "#{Color::YELLOW}Uso: adn db hito:renombrar #{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 [OPCIONES] + def comando_hito_actualizar(args) + if args.length < 2 + puts "#{Color::YELLOW}Uso: adn db hito:actualizar [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 diff --git a/tools/adn/comando/db/misc.rb b/tools/adn/comando/db/misc.rb new file mode 100644 index 00000000..3589da00 --- /dev/null +++ b/tools/adn/comando/db/misc.rb @@ -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 diff --git a/tools/adn/comando/db/nodo.rb b/tools/adn/comando/db/nodo.rb new file mode 100644 index 00000000..0bdfa1b2 --- /dev/null +++ b/tools/adn/comando/db/nodo.rb @@ -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