# frozen_string_literal: true # tools/adn/clients/dtic_bitacoras_api.rb — Cliente Ruby para API de dtic-BITACORAs # =============================================================================== # Este cliente permite interactuar con la API REST de dtic-BITACORAs desde Ruby. # Proporciona métodos para operaciones CRUD en nodos, bitácoras, entradas y proyectos. # # Configuración: # export DTIC_API_URL=http://localhost:3002 # o configurar en tools/adn/config.yml # # Uso básico: # require_relative 'clients/dtic_bitacoras_api' # client = DticBitacorasApi::Client.new # bitacoras = client.listar_bitacoras # client.crear_entrada(...) # # Sincronización: # client.sincronizar_md('bitacoras/2026-03-05.md') require 'net/http' require 'json' require 'uri' require 'date' require 'yaml' module DticBitacorasApi # Excepciones personalizadas class ApiError < StandardError attr_reader :status_code, :response_body def initialize(message, status_code = nil, response_body = nil) super(message) @status_code = status_code @response_body = response_body end def to_s msg = super msg += " (Status: #{@status_code})" if @status_code msg += "\nResponse: #{@response_body}" if @response_body msg end end class ConfigError < StandardError; end # Cliente principal class Client # Inicializa el cliente con configuración # # @param options [Hash] Opciones de configuración # @option options [String] :base_url URL base de la API (ej: http://localhost:3002) # @option options [String] :config_path Ruta al archivo de configuración YAML # @option options [Logger] :logger Instancia de logger (opcional) # @option options [Integer] :timeout_seconds Timeout para requests HTTP (default: 30) def initialize(options = {}) @config_path = options[:config_path] || File.join(__dir__, '..', 'config.yml') @base_url = options[:base_url] || extraer_url_desde_config @logger = options[:logger] || ADN.logger rescue nil @timeout_seconds = options[:timeout_seconds] || 30 # Validar URL base @base_url = @base_url.chomp('/') unless @base_url.start_with?('http://', 'https://') raise ConfigError, "URL base debe comenzar con http:// o https://: #{@base_url}" end @logger&.info("Cliente dtic-BITACORAs inicializado para #{@base_url}") end # --- MÉTODOS PARA NODOS --- # Lista todos los nodos activos # # @return [Array] Lista de nodos def listar_nodos get('/api/nodos') end # Obtiene un nodo por nombre # # @param nombre [String] Nombre del nodo # @return [Hash] Datos del nodo def obtener_nodo_por_nombre(nombre) nodos = listar_nodos nodos.find { |n| n['nombre'] == nombre } end # Crea un nuevo nodo # # @param nombre [String] Nombre del nodo # @param ip [String] Dirección IP # @param tipo [String] Tipo de nodo (servidor, vm, pc, hipervisor) # @param descripcion [String] Descripción opcional # @return [Hash] Nodo creado def crear_nodo(nombre, ip: nil, tipo: 'servidor', descripcion: '') post('/api/nodos', { nombre: nombre, ip: ip, tipo: tipo, descripcion: descripcion }) end # --- MÉTODOS PARA BITÁCORAS --- # Lista las últimas bitácoras (por defecto 30 días) # # @param limit [Integer] Límite de resultados # @return [Array] Lista de bitácoras def listar_bitacoras(limit: 30) get("/api/bitacoras?limit=#{limit}") end # Obtiene o crea una bitácora para una fecha específica # # @param fecha [String, Date] Fecha en formato YYYY-MM-DD # @return [Hash] Bitácora def obtener_bitacora(fecha) fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s get("/api/bitacoras?fecha=#{fecha_str}") end # Obtiene bitácora completa con entradas agrupadas por nodo # # @param fecha [String, Date] Fecha en formato YYYY-MM-DD # @return [Hash] Bitácora completa con nodos y entradas def obtener_bitacora_completa(fecha) fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s get("/api/bitacoras/#{fecha_str}/completa") end # Exporta bitácora a formato Markdown # # @param fecha [String, Date] Fecha en formato YYYY-MM-DD # @return [String] Contenido Markdown def exportar_bitacora_md(fecha) fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s get_raw("/api/bitacoras/#{fecha_str}/export") end # --- MÉTODOS PARA ENTRADAS --- # Lista entradas con filtros opcionales # # @param bitacora_id [Integer] ID de bitácora para filtrar # @param nodo_id [Integer] ID de nodo para filtrar # @return [Array] Lista de entradas def listar_entradas(bitacora_id: nil, nodo_id: nil) params = [] params << "bitacora_id=#{bitacora_id}" if bitacora_id params << "nodo_id=#{nodo_id}" if nodo_id query = params.empty? ? '' : "?#{params.join('&')}" get("/api/entradas#{query}") end # Crea una nueva entrada # # @param inicio [String] Hora de inicio (HH:MM) # @param fin [String, nil] Hora de fin (HH:MM) o nil si en curso # @param descripcion [String] Descripción de la actividad # @param bitacora_id [Integer] ID de la bitácora # @param nodo_id [Integer] ID del nodo # @param estado [String] Estado (✅, ⏳, ⚠️, ❌) # @param modo [String] Modo (P: Presencial, R: Remoto) # @param es_ia [Boolean] Si la acción fue realizada por IA # @return [Hash] Entrada creada def crear_entrada(inicio:, fin: nil, descripcion:, bitacora_id:, nodo_id:, estado: '⏳', modo: 'P', es_ia: false) post('/api/entradas', { inicio: inicio, fin: fin, descripcion: descripcion, estado: estado, modo: modo, es_ia: es_ia, bitacora_id: bitacora_id, nodo_id: nodo_id }) end # Actualiza una entrada existente # # @param id [Integer] ID de la entrada # @param attributes [Hash] Atributos a actualizar # @return [Hash] Entrada actualizada def actualizar_entrada(id, attributes) put("/api/entradas/#{id}", attributes) end # --- MÉTODOS PARA PROYECTOS --- # Lista todos los proyectos # # @return [Array] Lista de proyectos def listar_proyectos get('/api/proyectos') end # Obtiene un proyecto por código con detalles completos # # @param codigo [String] Código del proyecto (ej: P2601) # @return [Hash] Proyecto con fases, hitos y métricas def obtener_proyecto(codigo) get("/api/proyectos/#{codigo}") end # Crea un hito en un proyecto # # @param codigo_proyecto [String] Código del proyecto # @param fase_numero [Integer] Número de fase # @param id_hito [String] ID del hito (ej: SINC01) # @param titulo [String] Título del hito # @param options [Hash] Opciones adicionales # @option options [String] :descripcion Descripción del hito # @option options [String] :estado Estado del hito (✅, ⏳, 📍) # @option options [String] :fecha Fecha del hito (YYYY-MM-DD) # @option options [Integer] :horas_presencial Horas presenciales # @option options [Integer] :horas_remoto Horas remotas # @option options [String] :nodo_nombre Nombre del nodo asociado # @return [Hash] Hito creado def crear_hito(codigo_proyecto, fase_numero, id_hito, titulo, options = {}) post("/api/proyectos/#{codigo_proyecto}/hitos", { fase_numero: fase_numero, id_hito: id_hito, titulo: titulo, descripcion: options[:descripcion] || '', estado: options[:estado] || '⏳', fecha: options[:fecha], horas_presencial: options[:horas_presencial] || 0, horas_remoto: options[:horas_remoto] || 0, nodo_nombre: options[:nodo_nombre] }) end # --- SINCRONIZACIÓN DE ARCHIVOS MD --- # Sincroniza un archivo Markdown con la base de datos # # @param ruta_md [String] Ruta al archivo .md # @param options [Hash] Opciones de sincronización # @option options [Boolean] :sobrescribir Sobrescribir entradas existentes # @return [Hash] Resultado de la sincronización def sincronizar_md(ruta_md, options = {}) unless File.exist?(ruta_md) raise ApiError, "Archivo no encontrado: #{ruta_md}" end contenido = File.read(ruta_md, encoding: 'UTF-8') nombre_archivo = File.basename(ruta_md) # Extraer fecha del nombre del archivo match = nombre_archivo.match(/(\d{4}-\d{2}-\d{2})\.md/) unless match raise ApiError, "Nombre de archivo no sigue formato YYYY-MM-DD.md: #{nombre_archivo}" end fecha = match[1] @logger&.info("Sincronizando bitácora: #{fecha} desde #{ruta_md}") # Obtener o crear bitácora bitacora = obtener_bitacora(fecha) bitacora_id = bitacora['id'] # Obtener mapeo de nodos por nombre nodos = listar_nodos nodo_map = {} nodos.each { |n| nodo_map[n['nombre']] = n['id'] } # Si sobrescribir, eliminar entradas existentes if options[:sobrescribir] entradas = listar_entradas(bitacora_id: bitacora_id) entradas.each do |entrada| # Podríamos implementar delete si la API lo soporta @logger&.debug("Entrada existente que sería sobrescrita: #{entrada['id']}") end end # Parsear contenido MD entradas_encontradas = [] nodo_actual_id = nil en_seccion_detalladas = false contenido.each_line do |linea| linea = linea.chomp # Detectar inicio de sección de actividades detalladas if linea.include?('Actividades Detalladas') en_seccion_detalladas = true next end next unless en_seccion_detalladas # Detectar cambio de nodo if linea.start_with?('### ') nombre_nodo = linea.sub('### ', '').strip.split(' ')[0] nodo_actual_id = nodo_map[nombre_nodo] @logger&.debug("Cambiando a nodo: #{nombre_nodo} (ID: #{nodo_actual_id})") if nodo_actual_id next end next unless nodo_actual_id # Parsear entrada de tabla I-F-D-E entrada = parsear_linea_tabla(linea) if entrada entrada[:bitacora_id] = bitacora_id entrada[:nodo_id] = nodo_actual_id begin entrada_creada = crear_entrada(entrada) entradas_encontradas << entrada_creada @logger&.debug("Entrada creada: #{entrada[:inicio]} - #{entrada_creada['id']}") rescue ApiError => e @logger&.error("Error creando entrada: #{e.message}") end end end { fecha: fecha, bitacora_id: bitacora_id, entradas_procesadas: entradas_encontradas.size, entradas: entradas_encontradas } end # --- MÉTODOS DE UTILIDAD --- # Verifica salud de la API # # @return [Hash] Estado de la API def salud get('/health') end # Obtiene estadísticas del sistema # # @return [Hash] Estadísticas def estadisticas { nodos: listar_nodos.size, bitacoras: listar_bitacoras.size, proyectos: listar_proyectos.size } end private # Extrae URL base desde archivo de configuración def extraer_url_desde_config return 'http://localhost:3002' unless File.exist?(@config_path) begin config = YAML.load_file(@config_path) # Intentar obtener URL de dtic-BITACORAs if config['dtic_bitacoras'] && config['dtic_bitacoras']['api_url'] return config['dtic_bitacoras']['api_url'] end # Fallback a puertos predeterminados 'http://localhost:3002' rescue => e @logger&.error("Error cargando configuración: #{e.message}") 'http://localhost:3002' end end # Parsear línea de tabla I-F-D-E def parsear_linea_tabla(linea) # Formato: | HH:MM | HH:MM | Descripción | [P/R] Estado | regex = /\|\s*(\d{1,2}:\d{2})\s*\|\s*(\d{1,2}:\d{2}|\-)\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|/ match = linea.match(regex) return nil unless match inicio = match[1] fin = match[2] == '-' ? nil : match[2] descripcion_raw = match[3] estado_raw = match[4] # Detectar si es acción de IA es_ia = descripcion_raw.include?('(IA)') descripcion = descripcion_raw .gsub('(IA)', '') .gsub(/\*\*/, '') .gsub(/__/, '') .strip # Parsear modo y estado modo = 'P' # Default presencial estado = estado_raw if estado_raw.match(/\[([PR])\]\s*(.+)/) modo = $1 estado = $2 end { inicio: inicio, fin: fin, descripcion: descripcion, estado: estado, modo: modo, es_ia: es_ia } end # Métodos HTTP genéricos def get(path) request(:get, path) end def get_raw(path) request_raw(:get, path) end def post(path, data) request(:post, path, data) end def put(path, data) request(:put, path, data) end def request(method, path, data = nil) response = request_raw(method, path, data) JSON.parse(response) rescue JSON::ParserError => e raise ApiError.new("Error parseando respuesta JSON: #{e.message}", nil, response) end def request_raw(method, path, data = nil) uri = URI.join(@base_url, path) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = @timeout_seconds http.open_timeout = @timeout_seconds request_class = case method when :get then Net::HTTP::Get when :post then Net::HTTP::Post when :put then Net::HTTP::Put when :delete then Net::HTTP::Delete else raise "Método HTTP no soportado: #{method}" end request = request_class.new(uri.request_uri) request['Content-Type'] = 'application/json' request['User-Agent'] = "dtic-bitacoras-ruby-client/1.0" request.body = data.to_json if data && !data.empty? @logger&.debug("#{method.upcase} #{uri} #{data ? "con datos: #{data.inspect}" : ''}") begin response = http.request(request) rescue Net::ReadTimeout => e raise ApiError.new("Timeout después de #{@timeout_seconds} segundos: #{e.message}") rescue SocketError, Errno::ECONNREFUSED => e raise ApiError.new("No se puede conectar a #{@base_url}: #{e.message}") end @logger&.debug("Respuesta: #{response.code} #{response.message}") unless response.is_a?(Net::HTTPSuccess) error_message = "Error #{response.code}: #{response.message}" begin error_body = JSON.parse(response.body) error_message += " - #{error_body['error']}" if error_body['error'] rescue JSON::ParserError error_message += " - #{response.body[0..100]}" end raise ApiError.new(error_message, response.code, response.body) end response.body end end # Helper para uso rápido def self.client(options = {}) @client ||= Client.new(options) end end # Si se ejecuta directamente como script if __FILE__ == $0 begin client = DticBitacorasApi::Client.new puts "🧪 Probando conexión a #{client.instance_variable_get(:@base_url)}" # Prueba de salud salud = client.salud puts "✅ API saludable: #{salud['status']}" # Listar nodos nodos = client.listar_nodos puts "📊 Nodos encontrados: #{nodos.size}" # Listar bitácoras bitacoras = client.listar_bitacoras(limit: 5) puts "📅 Últimas bitácoras: #{bitacoras.map { |b| b['fecha'] }.join(', ')}" rescue DticBitacorasApi::ApiError => e puts "❌ Error de API: #{e.message}" exit 1 rescue DticBitacorasApi::ConfigError => e puts "❌ Error de configuración: #{e.message}" puts " Configura con: export DTIC_API_URL=http://localhost:3002" exit 1 end end