# frozen_string_literal: true # adn/tools/cli/nodos.rb — Subcomando para gestión y consulta de nodos # ====================================================================== # Uso: # ./adn/tools/run nodos listar [--tipo servidor|vm|pc] # ./adn/tools/run nodos agrupar [--formato arbol|tabla] # ./adn/tools/run nodos buscar require 'optparse' require_relative '../core/colores' require_relative 'nodos/info' require_relative '../core/help_formatter' module ADN class SubcomandoNodos def initialize(args, logger) @args = args @logger = logger @opciones = {} end def ejecutar if @args.empty? mostrar_ayuda return end accion = @args.shift case accion when 'info' info_nodo when 'agrupar' agrupar_nodos when 'listar' listar_nodos when 'buscar' buscar_nodos when 'ayuda', 'help', '-h', '--help' mostrar_ayuda else puts "#{Color::RED}✗ Acción desconocida: '#{accion}'#{Color::RESET}" mostrar_ayuda exit 1 end end private def mostrar_ayuda ayuda = ADN::HelpFormatter.generar_help( titulo: "Gestión de Nodos", descripcion: "Gestión y consulta de nodos de infraestructura", uso: "./adn/tools/run nodos [opciones]", opciones: [ { names: ['agrupar [--por ambitos|servidor]'], desc: "Agrupar nodos de la red" }, { names: ['listar [--tipo TIPO] [--ambito AMB]'], desc: "Listar fichas de nodos (.md)" }, { names: ['buscar '], desc: "Buscar nodos por nombre o contenido" } ], ejemplos: [ { cmd: "./adn/tools/run nodos agrupar --por ambitos", desc: "Agrupar nodos estructurados desde la DB" }, { cmd: "./adn/tools/run nodos agrupar --por servidor --servidor srv-pmox1", desc: "Agrupar VMs del servidor en base a fichas" }, { cmd: "./adn/tools/run nodos listar --ambito dtic-DASUTEN", desc: "Listar nodos MD cruzados con ámbito DB" }, { cmd: "./adn/tools/run nodos buscar dasuten", desc: "Buscar nodos relacionados con dasuten" } ] ) puts ayuda end # ─── Info ─────────────────────────────────────────────────────────── def info_nodo nombre = @args.shift unless nombre puts "#{Color::RED}✗ Se requiere nombre del nodo#{Color::RESET}" puts " Uso: ./adn/tools/run nodos info " return end metadata = ADN::NodosInfo.extraer_metadata(nombre) unless metadata puts "#{Color::RED}✗ Nodo '#{nombre}' no encontrado en nodos/#{Color::RESET}" return end puts "#{Color::CYAN}🖥️ Ficha: #{nombre}#{Color::RESET}" puts "#{Color::DIM}#{'─' * 50}#{Color::RESET}" campos = { 'IP' => metadata[:ip], 'Puerto SSH' => metadata[:puerto_ssh], 'Usuario' => metadata[:usuario], 'Auth' => metadata[:auth_type], 'Bóveda' => metadata[:clave_boveda], 'Host' => metadata[:host], 'SO' => metadata[:so], 'VMID' => metadata[:vmid] } campos.each do |label, valor| valor_str = valor ? valor.to_s : "#{Color::DIM}(no detectado)#{Color::RESET}" printf " #{Color::BOLD}%-14s#{Color::RESET} %s\n", label, valor_str end puts "#{Color::DIM}#{'─' * 50}#{Color::RESET}" puts "#{Color::DIM} Fuente: nodos/#{nombre}.md#{Color::RESET}" end # ─── Agrupar ──────────────────────────────────────────────────────── def agrupar_nodos opts = { servidor: nil, por: 'servidor' } parser = OptionParser.new do |opt| opt.on('--servidor SRV', 'Filtrar por servidor anfitrión') { |s| opts[:servidor] = s } opt.on('--por CAMPO', 'Agrupar por: servidor, ambitos') { |c| opts[:por] = c } end parser.order!(@args) if opts[:por] == 'ambitos' agrupar_por_ambitos else agrupar_por_servidor(opts) end end def agrupar_por_ambitos puts "#{Color::CYAN}📋 Agrupando nodos por Ámbitos (Desde DB)#{Color::RESET}" puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" require_relative '../db/core/bitacora_db' ambitos_hash = Hash.new { |h, k| h[k] = [] } sin_ambito = [] BitacorasDB::BitacoraDB.with_connection do |db| nodos = db.list_nodos(limit: 1000) nodos.each do |n| if n['ambito_nombre'] ambitos_hash[n['ambito_nombre']] << n['nombre'] else sin_ambito << n['nombre'] end end end ambitos_hash.keys.sort.each do |amb| vms = ambitos_hash[amb] puts "#{Color::GREEN}🏢 #{amb}#{Color::RESET} (#{vms.length} nodos)" vms.sort.each { |vm| puts " ├── 💻 #{vm}" } puts "" end if sin_ambito.any? puts "#{Color::YELLOW}❓ Nodos sin ámbito asignado:#{Color::RESET}" sin_ambito.sort.each { |nodo| puts " ├── 💻 #{nodo}" } puts "" end total_vms = ambitos_hash.values.flatten.length + sin_ambito.length puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" puts "#{Color::DIM}Total: #{ambitos_hash.keys.length} ámbitos, #{total_vms} nodos#{Color::RESET}" end def agrupar_por_servidor(opts) puts "#{Color::CYAN}📋 Agrupando nodos por Servidor Host#{Color::RESET}" puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" servidores = Hash.new { |h, k| h[k] = [] } sin_servidor = [] Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).each do |archivo| nodo_nombre = File.basename(archivo, '.md') next if nodo_nombre.start_with?('_') metadata = ADN::NodosInfo.extraer_metadata(nodo_nombre) host = metadata ? metadata[:host] : nil if host servidores[host] << nodo_nombre elsif !es_nodo_fisico?(nodo_nombre) sin_servidor << nodo_nombre end end # Filtrar por servidor si se especificó if opts[:servidor] srv = opts[:servidor] unless servidores.key?(srv) puts "#{Color::YELLOW}⚠ No se encontraron VMs para el servidor '#{srv}'#{Color::RESET}" return end mostrar_servidor(srv, servidores[srv]) else servidores.keys.sort.each { |srv| mostrar_servidor(srv, servidores[srv]) } if sin_servidor.any? puts "#{Color::YELLOW}❓ Nodos sin servidor anfitrión identificable:#{Color::RESET}" sin_servidor.sort.each { |nodo| puts " ├── 💻 #{nodo}" } puts "" end end total_vms = servidores.values.flatten.length puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" puts "#{Color::DIM}Total: #{servidores.keys.length} servidores, #{total_vms} VMs#{Color::RESET}" end def mostrar_servidor(srv, vms) puts "#{Color::GREEN}🏢 #{srv}#{Color::RESET} (#{vms.length} VMs)" vms.sort.each { |vm| puts " ├── 💻 #{vm}" } puts "" end def es_nodo_fisico?(nombre) nombre.start_with?('srv-') || nombre.start_with?('pc-') || nombre == 'dtic-bitacoras' || nombre == 'cam-hikvision-24' end # ─── Listar ───────────────────────────────────────────────────────── def listar_nodos opts = { tipo: nil, ambito: nil } parser = OptionParser.new do |opt| opt.on('--tipo TIPO', 'Filtrar por prefijo (srv, srvv, pcv, pc, cam, dc, sql)') { |t| opts[:tipo] = t } opt.on('--ambito NOMBRE', 'Cruzar nombres de base de datos para filtrar') { |a| opts[:ambito] = a } end parser.order!(@args) puts "#{Color::CYAN}📋 Listando fichas de nodos#{Color::RESET}" puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" nodos_validos = nil if opts[:ambito] require_relative '../db/core/bitacora_db' BitacorasDB::BitacoraDB.with_connection do |db| ambito = db.get_ambito(opts[:ambito]) if ambito nodos_db = db.list_nodos(ambito_id: ambito['id'], limit: 1000) nodos_validos = nodos_db.map { |n| n['nombre'] } else puts "#{Color::RED}✗ Error: Ámbito '#{opts[:ambito]}' no encontrado en la DB.#{Color::RESET}" return end end end archivos = Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).sort nodos = archivos.map { |a| File.basename(a, '.md') } nodos.reject! { |n| n.start_with?('_') } if opts[:tipo] nodos.select! { |n| n.start_with?(opts[:tipo]) } end if nodos_validos nodos.select! { |n| nodos_validos.include?(n) } end if nodos.empty? puts "#{Color::YELLOW}⚠ No se encontraron nodos#{opts[:tipo] ? " con prefijo '#{opts[:tipo]}'" : ''}#{Color::RESET}" return end nodos.each do |nodo| metadata = ADN::NodosInfo.extraer_metadata(nodo) rol = metadata && metadata[:host] ? 'VM' : (metadata && metadata[:so] ? metadata[:so].to_s : '-') puts " 💻 #{nodo.ljust(22)} #{Color::DIM}#{rol}#{Color::RESET}" end puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" puts "#{Color::DIM}Total: #{nodos.length} nodos#{Color::RESET}" end # ─── Buscar ───────────────────────────────────────────────────────── def buscar_nodos termino = @args.join(' ') if termino.empty? puts "#{Color::RED}✗ Se requiere un término de búsqueda#{Color::RESET}" puts " Uso: ./adn/tools/run nodos buscar " return end puts "#{Color::CYAN}🔍 Buscando nodos con '#{termino}'#{Color::RESET}" puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" resultados = [] Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).each do |archivo| nodo = File.basename(archivo, '.md') contenido = File.read(archivo, encoding: 'UTF-8') if nodo.include?(termino.downcase) || contenido.downcase.include?(termino.downcase) # Buscar línea con contexto lineas = contenido.split("\n") contexto = lineas.find { |l| l.downcase.include?(termino.downcase) } contexto = contexto ? contexto.strip[0..60] : '-' resultados << { nodo: nodo, contexto: contexto } end end if resultados.empty? puts "#{Color::YELLOW}⚠ No se encontraron nodos que coincidan con '#{termino}'#{Color::RESET}" return end resultados.each do |r| puts " 💻 #{r[:nodo].ljust(22)} #{Color::DIM}#{r[:contexto]}#{Color::RESET}" end puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}" puts "#{Color::DIM}Total: #{resultados.length} resultados#{Color::RESET}" end end end