- Reestructuración del CLI en módulos discretos (core/, comando/, config/, clientes/) - Migración del punto de entrada tools/adn.rb hacia el launcher ejecutable tools/adn/run - Simplificación de nombres de comandos (remoción prefijo subcomando_) - Corrección de rutas base transversales (PROJECT_ROOT, BITACORAS_DIR) - Reescritura integral de sentencias require_relative - Restauración de sintaxis en archivo sync.rb truncado
518 lines
16 KiB
Ruby
518 lines
16 KiB
Ruby
# 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<Hash>] 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<Hash>] 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<Hash>] 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<Hash>] 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
|