Refactor(ADN): Retiro de directorios y módulos obsoletos según principio Menos es Mas (spec/, clientes/, sync.rb)
This commit is contained in:
@@ -40,9 +40,6 @@ El proyecto sigue un patrón **Modular y Dirigido por Eventos**:
|
||||
|
||||
# Ver salud del sistema
|
||||
./tools/adn/run salud --json
|
||||
|
||||
# Sincronizar bitácoras con la API
|
||||
./tools/adn/run sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
# 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
|
||||
@@ -33,7 +33,6 @@ class SubcomandoAyuda
|
||||
generar nodo <nombre> Generar ficha de nodo
|
||||
#{Color::YELLOW}generar proyecto <código>#{Color::RESET} Generar manifiesto de proyecto
|
||||
#{Color::YELLOW}backup <nodo> [--mode MODO]#{Color::RESET} Ejecutar backup seguro de un nodo
|
||||
#{Color::YELLOW}sync [ruta|fecha]#{Color::RESET} Sincronizar con API dtic-BITACORAs
|
||||
#{Color::YELLOW}inicio de jornada [presencial|remoto]#{Color::RESET} Inicio de jornada laboral
|
||||
#{Color::YELLOW}cierre de jornada [presencial|remoto]#{Color::RESET} Cierre de jornada laboral
|
||||
#{Color::YELLOW}triggers [opciones]#{Color::RESET} Gestión del motor de triggers
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
|
||||
# frozen_string_literal: true
|
||||
|
||||
# tools/adn/subcomando_sync.rb — Subcomando para sincronizar archivos MD con API dtic-BITACORAs
|
||||
# =============================================================================================
|
||||
# Este subcomando permite sincronizar bitácoras en formato Markdown con la base de datos
|
||||
# de dtic-BITACORAs mediante la API REST.
|
||||
|
||||
require_relative '../clientes/dtic_bitacoras_api'
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
|
||||
module ADN
|
||||
class SubcomandoSync
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = {
|
||||
sobrescribir: false,
|
||||
validar_primero: true,
|
||||
batch: false,
|
||||
fecha: nil,
|
||||
ruta: nil
|
||||
}
|
||||
@resultados = {
|
||||
archivos_procesados: 0,
|
||||
archivos_exitosos: 0,
|
||||
archivos_fallidos: 0,
|
||||
entradas_sincronizadas: 0,
|
||||
errores: []
|
||||
}
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./tools/adn/run sync [OPCIONES] [RUTA_O_FECHA]"
|
||||
opts.separator ""
|
||||
opts.separator "Sincroniza archivos Markdown con la API de dtic-BITACORAs"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--sobrescribir", "Sobrescribir entradas existentes en la base de datos") do
|
||||
@opciones[:sobrescribir] = true
|
||||
end
|
||||
opts.on("--no-validar", "No validar archivos antes de sincronizar") do
|
||||
@opciones[:validar_primero] = false
|
||||
end
|
||||
opts.on("--batch", "Sincronizar todos los archivos en el directorio bitacoras/") do
|
||||
@opciones[:batch] = true
|
||||
end
|
||||
opts.on("--fecha FECHA", "Sincronizar bitácora específica (formato: YYYY-MM-DD)") do |f|
|
||||
@opciones[:fecha] = f
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Determinar qué sincronizar
|
||||
if @args.any? && !@opciones[:batch] && !@opciones[:fecha]
|
||||
@opciones[:ruta] = @args[0]
|
||||
end
|
||||
|
||||
ejecutar_sincronizacion
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ejecutar_sincronizacion
|
||||
@logger.info("Iniciando sincronización con API dtic-BITACORAs", @opciones)
|
||||
|
||||
# Inicializar cliente API
|
||||
begin
|
||||
@client = DticBitacorasApi::Client.new(logger: @logger)
|
||||
salud = @client.salud
|
||||
@logger.exito("API dtic-BITACORAs disponible: #{salud['status']}")
|
||||
rescue DticBitacorasApi::ApiError => e
|
||||
@logger.error("No se puede conectar a la API: #{e.message}")
|
||||
mostrar_conexion_fallback
|
||||
exit 1
|
||||
rescue DticBitacorasApi::ConfigError => e
|
||||
@logger.error("Error de configuración: #{e.message}")
|
||||
puts "Configura la URL de la API en tools/adn/config.yml o con export DTIC_API_URL=http://localhost:3002"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Determinar archivos a sincronizar
|
||||
archivos = determinar_archivos_a_sincronizar
|
||||
|
||||
if archivos.empty?
|
||||
@logger.advertencia("No se encontraron archivos para sincronizar")
|
||||
mostrar_estadisticas
|
||||
exit 0
|
||||
end
|
||||
|
||||
puts "\n#{Color::CYAN}🔄 Sincronizando #{archivos.size} archivo(s) con dtic-BITACORAs#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
# Procesar cada archivo
|
||||
archivos.each_with_index do |archivo, index|
|
||||
procesar_archivo(archivo, index + 1, archivos.size)
|
||||
end
|
||||
|
||||
mostrar_estadisticas
|
||||
end
|
||||
|
||||
def determinar_archivos_a_sincronizar
|
||||
archivos = []
|
||||
|
||||
if @opciones[:batch]
|
||||
# Sincronizar todos los archivos en bitacoras/
|
||||
patron = File.join(ADN::BITACORAS_DIR, "*.md")
|
||||
archivos = Dir.glob(patron).sort
|
||||
elsif @opciones[:fecha]
|
||||
# Sincronizar archivo específico por fecha
|
||||
ruta = File.join(ADN::BITACORAS_DIR, "#{@opciones[:fecha]}.md")
|
||||
archivos = File.exist?(ruta) ? [ruta] : []
|
||||
elsif @opciones[:ruta]
|
||||
# Sincronizar archivo específico por ruta
|
||||
archivos = [File.expand_path(@opciones[:ruta])]
|
||||
else
|
||||
# Sincronizar archivo del día actual
|
||||
fecha_hoy = Date.today.strftime('%Y-%m-%d')
|
||||
ruta_hoy = File.join(ADN::BITACORAS_DIR, "#{fecha_hoy}.md")
|
||||
archivos = File.exist?(ruta_hoy) ? [ruta_hoy] : []
|
||||
end
|
||||
|
||||
archivos
|
||||
end
|
||||
|
||||
def procesar_archivo(ruta_archivo, indice, total)
|
||||
nombre = File.basename(ruta_archivo)
|
||||
puts "\n#{Color::CYAN}[#{indice}/#{total}] #{nombre}#{Color::RESET}"
|
||||
|
||||
unless File.exist?(ruta_archivo)
|
||||
@resultados[:errores] << "#{nombre}: Archivo no encontrado"
|
||||
@resultados[:archivos_fallidos] += 1
|
||||
puts " #{Color::RED}✗ Archivo no encontrado#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
# Extraer fecha del nombre del archivo
|
||||
fecha_match = nombre.match(/(\d{4}-\d{2}-\d{2})\.md/)
|
||||
unless fecha_match
|
||||
@resultados[:errores] << "#{nombre}: Formato de nombre inválido (debe ser YYYY-MM-DD.md)"
|
||||
@resultados[:archivos_fallidos] += 1
|
||||
puts " #{Color::RED}✗ Formato de nombre inválido#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
fecha = fecha_match[1]
|
||||
|
||||
# Validar formato del archivo si está habilitado
|
||||
if @opciones[:validar_primero]
|
||||
unless validar_archivo(ruta_archivo)
|
||||
@resultados[:errores] << "#{nombre}: Falló validación previa"
|
||||
@resultados[:archivos_fallidos] += 1
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Sincronizar con la API
|
||||
begin
|
||||
resultado = @client.sincronizar_md(ruta_archivo, sobrescribir: @opciones[:sobrescribir])
|
||||
|
||||
@resultados[:archivos_procesados] += 1
|
||||
@resultados[:archivos_exitosos] += 1
|
||||
@resultados[:entradas_sincronizadas] += resultado[:entradas_procesadas]
|
||||
|
||||
puts " #{Color::GREEN}✓ Sincronizado exitosamente#{Color::RESET}"
|
||||
puts " #{Color::DIM}Bitácora ID: #{resultado[:bitacora_id]}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Entradas: #{resultado[:entradas_procesadas]}#{Color::RESET}"
|
||||
|
||||
@logger.info("Archivo sincronizado: #{nombre}", resultado)
|
||||
rescue DticBitacorasApi::ApiError => e
|
||||
@resultados[:archivos_procesados] += 1
|
||||
@resultados[:archivos_fallidos] += 1
|
||||
@resultados[:errores] << "#{nombre}: #{e.message}"
|
||||
|
||||
puts " #{Color::RED}✗ Error de API: #{e.message}#{Color::RESET}"
|
||||
@logger.error("Error sincronizando #{nombre}: #{e.message}")
|
||||
rescue => e
|
||||
@resultados[:archivos_procesados] += 1
|
||||
@resultados[:archivos_fallidos] += 1
|
||||
@resultados[:errores] << "#{nombre}: Error inesperado - #{e.message}"
|
||||
|
||||
puts " #{Color::RED}✗ Error inesperado: #{e.message}#{Color::RESET}"
|
||||
@logger.error("Error inesperado en #{nombre}: #{e.message}", backtrace: e.backtrace)
|
||||
end
|
||||
end
|
||||
|
||||
def validar_archivo(ruta_archivo)
|
||||
puts " #{Color::CYAN}🔍 Validando formato...#{Color::RESET}"
|
||||
|
||||
contenido = File.read(ruta_archivo, encoding: 'UTF-8')
|
||||
errores_validacion = []
|
||||
|
||||
# Validar formato básico
|
||||
unless contenido.include?('## 📝 Actividades Detalladas')
|
||||
errores_validacion << "Falta sección '## 📝 Actividades Detalladas'"
|
||||
end
|
||||
|
||||
# Validar que tenga al menos una tabla de entradas
|
||||
unless contenido.match(/\|\s*[I]\s*\|\s*[F]\s*\|\s*Descripción\s*\|\s*[E]\s*\|/)
|
||||
errores_validacion << "No se encontró tabla de entradas I-F-D-E"
|
||||
end
|
||||
|
||||
# Validar formato de tiempo
|
||||
if contenido.match(/\d{1,2}:\d{2}\s*hs/)
|
||||
errores_validacion << "Formato de tiempo incorrecto (contiene 'hs')"
|
||||
end
|
||||
|
||||
if errores_validacion.empty?
|
||||
puts " #{Color::GREEN}✓ Validación exitosa#{Color::RESET}"
|
||||
true
|
||||
else
|
||||
puts " #{Color::YELLOW}⚠ Advertencias de validación:#{Color::RESET}"
|
||||
errores_validacion.each { |e| puts " - #{e}" }
|
||||
puts " #{Color::DIM}Continuando con sincronización...#{Color::RESET}"
|
||||
true # Continuar a pesar de advertencias
|
||||
end
|
||||
end
|
||||
|
||||
def mostrar_estadisticas
|
||||
puts "\n#{Color::CYAN}📊 Resultados de sincronización#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
puts " #{Color::GREEN}Archivos procesados:#{Color::RESET} #{@resultados[:archivos_procesados]}"
|
||||
puts " #{Color::GREEN}Archivos exitosos:#{Color::RESET} #{@resultados[:archivos_exitosos]}"
|
||||
|
||||
if @resultados[:archivos_fallidos] > 0
|
||||
puts " #{Color::RED}Archivos fallidos:#{Color::RESET} #{@resultados[:archivos_fallidos]}"
|
||||
else
|
||||
puts " #{Color::GREEN}Archivos fallidos:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
puts " #{Color::GREEN}Entradas sincronizadas:#{Color::RESET} #{@resultados[:entradas_sincronizadas]}"
|
||||
|
||||
# Mostrar estadísticas de la API
|
||||
mostrar_estadisticas_api
|
||||
|
||||
# Mostrar errores si los hay
|
||||
unless @resultados[:errores].empty?
|
||||
puts "\n#{Color::YELLOW}⚠ Errores encontrados:#{Color::RESET}"
|
||||
@resultados[:errores].each do |error|
|
||||
puts " #{Color::RED}• #{error}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# Resumen final
|
||||
if @resultados[:archivos_fallidos] == 0
|
||||
@logger.exito("Sincronización completada exitosamente")
|
||||
puts "\n#{Color::GREEN}✅ Sincronización completada exitosamente#{Color::RESET}"
|
||||
else
|
||||
@logger.advertencia("Sincronización completada con errores")
|
||||
puts "\n#{Color::YELLOW}⚠ Sincronización completada con #{@resultados[:archivos_fallidos]} error(es)#{Color::RESET}"
|
||||
end
|
||||
|
||||
@logger.info("Resumen de sincronización", @resultados)
|
||||
end
|
||||
|
||||
def mostrar_estadisticas_api
|
||||
begin
|
||||
stats = @client.estadisticas
|
||||
puts "\n#{Color::CYAN}📈 Estadísticas de dtic-BITACORAs#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 40}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Nodos en sistema:#{Color::RESET} #{stats[:nodos] || 0}"
|
||||
puts " #{Color::GREEN}Bitácoras en sistema:#{Color::RESET} #{stats[:bitacoras] || 0}"
|
||||
rescue => e
|
||||
puts " #{Color::RED}No se pudieron obtener estadísticas: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -46,11 +46,9 @@ require_relative 'core/logger'
|
||||
require_relative 'core/eventos'
|
||||
require_relative 'core/triggers'
|
||||
require_relative 'core/validador'
|
||||
require_relative 'clientes/dtic_bitacoras_api'
|
||||
require_relative 'comando/db'
|
||||
require_relative 'comando/plan'
|
||||
require_relative 'comando/backup'
|
||||
require_relative 'comando/sync'
|
||||
require_relative 'comando/triggers'
|
||||
require_relative 'comando/conocimiento'
|
||||
require_relative 'comando/cierre'
|
||||
@@ -103,8 +101,6 @@ class ADNCLI
|
||||
ADN::SubcomandoDB.new(args, @logger).ejecutar
|
||||
when 'backup', 'backups'
|
||||
ADN::SubcomandoBackup.new(args, @logger).ejecutar
|
||||
when 'sync'
|
||||
ADN::SubcomandoSync.new(args, @logger).ejecutar
|
||||
when 'triggers'
|
||||
ADN::SubcomandoTriggers.new(args, @logger).ejecutar
|
||||
when 'cierre'
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'minitest/autorun'
|
||||
require_relative '../../bitacoras-db/core/bitacora_db'
|
||||
require 'date'
|
||||
|
||||
module ADN
|
||||
class BitacoraDBTest < Minitest::Test
|
||||
def setup
|
||||
# Usar el entorno development encapsulado en transacción
|
||||
# Cualquier cambio persistente será anulado tras cada bloque de prueba.
|
||||
@db = BitacorasDB::BitacoraDB.new(env: :development)
|
||||
@db.connect
|
||||
@db.execute('BEGIN')
|
||||
end
|
||||
|
||||
def teardown
|
||||
# Abortar la transacción para dejar la db limpia, luego desconectar
|
||||
@db.execute('ROLLBACK') rescue nil
|
||||
@db.disconnect
|
||||
end
|
||||
|
||||
def test_connection_health
|
||||
assert @db.connection_test?
|
||||
salud = @db.health_check
|
||||
assert_equal 'healthy', salud[:status]
|
||||
assert salud[:bitacoras_count] >= 0
|
||||
end
|
||||
|
||||
def test_nodo_crud
|
||||
# Crear nodo
|
||||
nombre_nodo = "test_nodo_#{Time.now.to_i}"
|
||||
nodo = @db.create_nodo(nombre: nombre_nodo, tipo: 'servidor', descripcion: 'Nodo de pruebas UT')
|
||||
assert nodo['id']
|
||||
assert_equal nombre_nodo, nodo['nombre']
|
||||
|
||||
# Listar nodos (debería encontrar al menos al nuevo nodo)
|
||||
nodos = @db.list_nodos
|
||||
encontrado = nodos.find { |n| n['nombre'] == nombre_nodo }
|
||||
refute_nil encontrado
|
||||
|
||||
# Encontrar por nombre
|
||||
buscado = @db.find_nodo_by_nombre(nombre_nodo)
|
||||
assert_equal nodo['id'], buscado['id']
|
||||
end
|
||||
|
||||
def test_bitacora_crud
|
||||
# Usar fecha en el futuro lejano para no colisionar con reales si falla el db
|
||||
fecha_futura = Date.new(2050, 1, 1)
|
||||
|
||||
# Crear Bitácora
|
||||
resumen_test = 'Tests unitarios automatizados'
|
||||
bitacora = @db.create_bitacora(fecha: fecha_futura, resumen: resumen_test)
|
||||
assert bitacora['id']
|
||||
assert_equal fecha_futura.to_s, bitacora['fecha']
|
||||
|
||||
# Encontrar por fecha
|
||||
buscada = @db.find_bitacora_by_fecha(fecha_futura)
|
||||
assert_equal bitacora['id'], buscada['id']
|
||||
|
||||
# Listar filtrando fechas
|
||||
rango = @db.list_bitacoras(desde: fecha_futura.to_s, hasta: fecha_futura.to_s)
|
||||
assert_equal 1, rango.length
|
||||
assert_equal bitacora['id'], rango.first['id']
|
||||
end
|
||||
|
||||
def test_entrada_crud_con_busqueda
|
||||
# 1. Setup Base - Nodo y Bitacora
|
||||
fecha_act = Date.new(2050, 2, 2)
|
||||
bitacora = @db.create_bitacora(fecha: fecha_act)
|
||||
nodo = @db.create_nodo(nombre: "test_nodo_ev_#{Time.now.to_i}", tipo: 'vm')
|
||||
|
||||
# 2. Crear entrada
|
||||
desc_unica = "Validacion especial MINITEST_#{Time.now.to_i}"
|
||||
entrada = @db.create_entrada(
|
||||
inicio: '10:00:00',
|
||||
descripcion: desc_unica,
|
||||
fin: '11:00:00',
|
||||
estado: '✅',
|
||||
modo: 'R',
|
||||
es_ia: true,
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo['id']
|
||||
)
|
||||
|
||||
assert entrada['id']
|
||||
assert_equal '10:00:00', entrada['inicio']
|
||||
assert_equal 'R', entrada['modo']
|
||||
|
||||
# 3. Consultar y verificar búsqueda/filtrado implementada en Tarea 2.2
|
||||
entradas = @db.list_entradas(
|
||||
buscar: 'MINITEST_',
|
||||
desde: '2050-02-01',
|
||||
hasta: '2050-02-03'
|
||||
)
|
||||
|
||||
assert entradas.length >= 1
|
||||
hallazgo = entradas.find { |e| e['id'] == entrada['id'] }
|
||||
refute_nil hallazgo
|
||||
assert_equal desc_unica, hallazgo['descripcion']
|
||||
assert_equal bitacora['fecha'], hallazgo['bitacora_fecha']
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user