Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA

This commit is contained in:
Ricardo Monla
2026-03-10 22:25:01 -03:00
parent 174ef7d53c
commit 78006032eb
69 changed files with 432 additions and 671 deletions
+839
View File
@@ -0,0 +1,839 @@
# frozen_string_literal: true
# adn/tools/db/core/bitacora_db.rb — Clase principal para conexión a PostgreSQL
# ================================================================================
# Esta clase proporciona una interfaz Ruby para interactuar con la base de datos
# dtic-BITACORAs. Maneja conexiones, operaciones CRUD y validaciones para las
# tablas del esquema 'bitacoras':
# • nodos - Servidores, VMs, PCs del sistema
# • bitacoras - Bitácoras diarias (fecha única)
# • temas - Agrupadores de entradas por nodo
# • entradas - Entradas cronológicas en formato I-F-D-E
#
# Uso básico:
# db = BitacorasDB::BitacoraDB.new
# db.connection_test? # Verificar conexión
# bitacoras = db.list_bitacoras(limit: 10)
# db.create_entrada(inicio: '09:00', fin: '10:00', descripcion: 'Reunión', nodo_id: 1)
#
# Uso con bloque (cierre automático):
# BitacorasDB::BitacoraDB.with_connection do |db|
# db.create_bitacora(fecha: Date.today)
# end
#
# Configuración:
# La configuración se carga desde tools/config/database.yml
# Entorno por defecto: development (contenedor Docker en puerto 5433)
require 'pg'
require 'yaml'
require 'json'
require 'date'
require 'time'
module BitacorasDB
class BitacoraDB
# Constantes para el esquema y tablas
SCHEMA = 'bitacoras'
TABLES = {
nodos: 'nodos',
bitacoras: 'bitacoras',
temas: 'temas',
entradas: 'entradas'
}.freeze
# Estados válidos para entradas y temas
ESTADOS_VALIDOS = ['⏳', '✅', '❌', '⚠️', '⏸️'].freeze
# Modos válidos para entradas
MODOS_VALIDOS = ['P', 'R', 'S'].freeze # P=Presencial, R=Remoto, S=Servicio
# Inicializa la conexión a la base de datos
#
# @param env [Symbol, String] Entorno (:development, :test, :production)
# @param config_path [String] Ruta al archivo de configuración YAML
# @param logger [Object] Instancia de logger (por defecto ADN.logger si disponible)
def initialize(env: :development, config_path: nil, logger: nil)
@env = env.to_s
@config_path = config_path || default_config_path
@config = load_config
@connection = nil
@logger = logger || default_logger
@connected = false
end
# Carga la configuración desde el archivo YAML
#
# @return [Hash] Configuración para el entorno actual
# @raise [RuntimeError] Si no se encuentra la configuración o el entorno
def load_config
unless File.exist?(@config_path)
raise "Archivo de configuración no encontrado: #{@config_path}"
end
config = YAML.load_file(@config_path)
unless config && config[@env]
raise "Entorno '#{@env}' no encontrado en #{@config_path}. Entornos disponibles: #{config.keys.join(', ')}"
end
config[@env]
end
# Establece conexión con la base de datos
#
# @return [PG::Connection] Conexión PostgreSQL
# @raise [PG::Error] Si falla la conexión
def connect
return @connection if @connected && @connection && !@connection.finished?
begin
@logger&.info("Conectando a PostgreSQL: #{@config['host']}:#{@config['port']}/#{@config['database']}")
@connection = PG.connect(
host: @config['host'],
port: @config['port'],
dbname: @config['database'],
user: @config['username'],
password: @config['password'],
connect_timeout: @config['timeout'] || 5000
)
# Configurar búsqueda de esquema
if @config['schema_search_path']
@connection.exec("SET search_path TO #{@config['schema_search_path']}")
end
@connected = true
@logger&.info("Conexión a PostgreSQL establecida correctamente")
@connection
rescue PG::Error => e
@connected = false
@logger&.error("Error conectando a PostgreSQL: #{e.message}")
raise
end
end
# Cierra la conexión con la base de datos
def disconnect
if @connected && @connection && !@connection.finished?
@connection.close
@connected = false
@logger&.info("Conexión a PostgreSQL cerrada")
end
@connection = nil
end
# Ejecuta un bloque con una conexión, cerrando automáticamente al final
#
# @yield [BitacoraDB] Instancia de BitacoraDB con conexión establecida
# @return [Object] Resultado del bloque
def with_connection(&block)
connect
yield self
ensure
disconnect
end
# Método de clase para uso con bloque
#
# @param env [Symbol] Entorno a usar
# @param config_path [String] Ruta al archivo de configuración
# @yield [BitacoraDB] Instancia de BitacoraDB
# @return [Object] Resultado del bloque
def self.with_connection(env: :development, config_path: nil, &block)
db = new(env: env, config_path: config_path)
db.with_connection(&block)
end
# Verifica si la conexión a la base de datos está activa
#
# @return [Boolean] true si la conexión es válida
def connection_test?
return false unless @connected && @connection && !@connection.finished?
begin
@connection.exec('SELECT 1')
true
rescue PG::Error
false
end
end
# Obtiene información de salud de la base de datos
#
# @return [Hash] Información de estado y estadísticas
def health_check
return { status: 'disconnected', message: 'No hay conexión activa' } unless connection_test?
begin
result = @connection.exec('SELECT version()').first
stats = @connection.exec('SELECT COUNT(*) as total FROM bitacoras.bitacoras').first
{
status: 'healthy',
postgres_version: result['version'],
bitacoras_count: stats['total'].to_i,
timestamp: Time.now.iso8601,
connection: {
host: @config['host'],
port: @config['port'],
database: @config['database']
}
}
rescue PG::Error => e
{
status: 'unhealthy',
message: e.message,
timestamp: Time.now.iso8601
}
end
end
# OPERACIONES CRUD PARA BITÁCORAS
# Crea una nueva bitácora diaria
#
# @param fecha [Date, String] Fecha de la bitácora
# @param resumen [String, nil] Resumen opcional
# @return [Hash] Bitácora creada con ID
# @raise [PG::Error] Si ya existe bitácora para esa fecha
def create_bitacora(fecha:, resumen: nil)
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen)
VALUES ($1, $2)
RETURNING id, fecha, resumen, created_at, updated_at
SQL
result = @connection.exec_params(query, [fecha.to_s, resumen])
result.first
end
# Obtiene bitácoras con paginación y filtros
#
# @param limit [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento para paginación
# @param desde [Date, String, nil] Fecha desde
# @param hasta [Date, String, nil] Fecha hasta
# @return [Array<Hash>] Lista de bitácoras
def list_bitacoras(limit: 50, offset: 0, desde: nil, hasta: nil)
conditions = []
params = []
param_index = 1
if desde
conditions << "fecha >= $#{param_index}"
params << Date.parse(desde.to_s).to_s
param_index += 1
end
if hasta
conditions << "fecha <= $#{param_index}"
params << Date.parse(hasta.to_s).to_s
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, fecha, resumen, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
#{where_clause}
ORDER BY fecha DESC
LIMIT $#{param_index} OFFSET $#{param_index + 1}
SQL
params << limit
params << offset
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# Busca una bitácora por fecha
#
# @param fecha [Date, String] Fecha a buscar
# @return [Hash, nil] Bitácora encontrada o nil
def find_bitacora_by_fecha(fecha)
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
query = <<~SQL
SELECT id, fecha, resumen, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
WHERE fecha = $1
SQL
result = @connection.exec_params(query, [fecha.to_s])
result.first&.to_h
end
# OPERACIONES CRUD PARA NODOS
# Crea un nuevo nodo (servidor, VM, PC)
#
# @param nombre [String] Nombre único del nodo
# @param ip [String, nil] Dirección IP
# @param tipo [String] Tipo ('servidor', 'hipervisor', 'vm', 'pc')
# @param descripcion [String, nil] Descripción opcional
# @param activo [Boolean] Si el nodo está activo
# @return [Hash] Nodo creado con ID
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true)
tipos_validos = ['servidor', 'hipervisor', 'vm', 'pc', 'vmware', 'xenserver', 'servicio']
unless tipos_validos.include?(tipo.downcase)
raise ArgumentError, "Tipo de nodo inválido. Válidos: #{tipos_validos.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
SQL
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo])
result.first
end
# Lista nodos con filtros
#
# @param activo [Boolean, nil] Filtrar por estado activo
# @param tipo [String, nil] Filtrar por tipo
# @param limit [Integer] Límite de resultados
# @return [Array<Hash>] Lista de nodos
def list_nodos(activo: nil, tipo: nil, limit: 100)
conditions = []
params = []
param_index = 1
if !activo.nil?
conditions << "activo = $#{param_index}"
params << activo
param_index += 1
end
if tipo
conditions << "tipo = $#{param_index}"
params << tipo
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:nodos]}
#{where_clause}
ORDER BY nombre
LIMIT $#{param_index}
SQL
params << limit
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# Busca nodo por nombre
#
# @param nombre [String] Nombre del nodo
# @return [Hash, nil] Nodo encontrado o nil
def find_nodo_by_nombre(nombre)
query = <<~SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:nodos]}
WHERE nombre = $1
SQL
result = @connection.exec_params(query, [nombre])
result.first&.to_h
end
# OPERACIONES CRUD PARA ENTRADAS (I-F-D-E)
# Crea una nueva entrada cronológica
#
# @param inicio [String, Time] Hora de inicio (formato HH:MM o Time)
# @param descripcion [String] Descripción de la actividad
# @param fin [String, Time, nil] Hora de fin (opcional)
# @param estado [String] Estado (⏳, ✅, ❌, ⚠️, ⏸️)
# @param modo [String] Modo (P=Presencial, R=Remoto)
# @param es_ia [Boolean] Si fue generado por IA
# @param tema_id [Integer, nil] ID del tema relacionado
# @param bitacora_id [Integer, nil] ID de la bitácora
# @param nodo_id [Integer, nil] ID del nodo
# @return [Hash] Entrada creada con ID
def create_entrada(inicio:, descripcion:, fin: nil, estado: nil, modo: nil, es_ia: false,
tema_id: nil, bitacora_id: nil, nodo_id: nil)
# Validar formato de tiempo
inicio_time = parse_time(inicio)
fin_time = parse_time(fin) if fin
# Inferir Estado
if estado.nil? || estado.strip.empty?
estado = fin_time ? '✅' : '⏳'
end
# Inferir Modo
if modo.nil? || modo.strip.empty?
if bitacora_id
res = @connection.exec_params("SELECT modo FROM #{SCHEMA}.#{TABLES[:entradas]} WHERE bitacora_id = $1 ORDER BY id DESC LIMIT 1", [bitacora_id])
modo = res.ntuples > 0 ? res[0]['modo'] : 'P'
else
modo = 'P'
end
end
# Validar estado
unless ESTADOS_VALIDOS.include?(estado)
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
end
# Validar modo
unless MODOS_VALIDOS.include?(modo)
raise ArgumentError, "Modo inválido. Válidos: #{MODOS_VALIDOS.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:entradas]} (
inicio, fin, descripcion, estado, modo, es_ia,
tema_id, bitacora_id, nodo_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, inicio, fin, descripcion, estado, modo, es_ia,
tema_id, bitacora_id, nodo_id, created_at, updated_at
SQL
result = @connection.exec_params(
query,
[inicio_time, fin_time, descripcion, estado, modo, es_ia,
tema_id, bitacora_id, nodo_id]
)
result.first
end
# Actualiza una entrada existente
def update_entrada(id, params)
# Auto-inferir estado si se está mandando una hora de fin y no se fuerza un estado explícito
if params.key?(:fin) && !params.key?(:estado)
if params[:fin].nil? || params[:fin].to_s.strip.empty?
params[:estado] = '⏳'
else
params[:estado] = '✅'
end
end
set_clauses = []
values = []
i = 1
params.each do |k, v|
if [:fin, :descripcion, :estado, :modo, :es_ia].include?(k)
val = (k == :fin) ? parse_time(v) : v
set_clauses << "#{k} = $#{i}"
values << val
i += 1
end
end
return nil if set_clauses.empty?
query = <<~SQL
UPDATE #{SCHEMA}.#{TABLES[:entradas]}
SET #{set_clauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = $#{i}
RETURNING *
SQL
values << id
result = @connection.exec_params(query, values)
result.first&.to_h
end
# Lista entradas con filtros avanzados
#
# @param bitacora_id [Integer, nil] Filtrar por bitácora
# @param nodo_id [Integer, nil] Filtrar por nodo
# @param tema_id [Integer, nil] Filtrar por tema
# @param estado [String, nil] Filtrar por estado
# @param desde [Time, String, nil] Fecha/hora desde
# @param hasta [Time, String, nil] Fecha/hora hasta
# @param limit [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @return [Array<Hash>] Lista de entradas
def list_entradas(bitacora_id: nil, nodo_id: nil, tema_id: nil, estado: nil,
desde: nil, hasta: nil, buscar: nil, limit: 100, offset: 0)
conditions = []
params = []
param_index = 1
if bitacora_id
conditions << "e.bitacora_id = $#{param_index}"
params << bitacora_id
param_index += 1
end
if nodo_id
conditions << "e.nodo_id = $#{param_index}"
params << nodo_id
param_index += 1
end
if tema_id
conditions << "e.tema_id = $#{param_index}"
params << tema_id
param_index += 1
end
if estado
conditions << "e.estado = $#{param_index}"
params << estado
param_index += 1
end
if desde
conditions << "b.fecha >= $#{param_index}"
params << Date.parse(desde.to_s).to_s
param_index += 1
end
if hasta
conditions << "b.fecha <= $#{param_index}"
params << Date.parse(hasta.to_s).to_s
param_index += 1
end
if buscar
conditions << "e.descripcion ILIKE $#{param_index}"
params << "%#{buscar}%"
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
b.fecha as bitacora_fecha,
n.nombre as nodo_nombre,
t.titulo as tema_titulo
FROM #{SCHEMA}.#{TABLES[:entradas]} e
LEFT JOIN #{SCHEMA}.#{TABLES[:bitacoras]} b ON e.bitacora_id = b.id
LEFT JOIN #{SCHEMA}.#{TABLES[:nodos]} n ON e.nodo_id = n.id
LEFT JOIN #{SCHEMA}.#{TABLES[:temas]} t ON e.tema_id = t.id
#{where_clause}
ORDER BY COALESCE(b.fecha, CURRENT_DATE) DESC, e.inicio DESC
LIMIT $#{param_index} OFFSET $#{param_index + 1}
SQL
params << limit
params << offset
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# OPERACIONES CRUD PARA TEMAS
# Crea un nuevo tema (agrupador de entradas)
#
# @param id_tema [String] Identificador único del tema
# @param titulo [String] Título del tema
# @param estado [String] Estado actual
# @param descripcion [String, nil] Descripción opcional
# @param bitacora_id [Integer, nil] ID de la bitácora relacionada
# @param nodo_id [Integer, nil] ID del nodo relacionado
# @return [Hash] Tema creado con ID
def create_tema(id_tema:, titulo:, estado: '⏳', descripcion: nil,
bitacora_id: nil, nodo_id: nil)
unless ESTADOS_VALIDOS.include?(estado)
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
end
query = <<~SQL
INSERT INTO #{SCHEMA}.#{TABLES[:temas]} (
id_tema, titulo, estado, descripcion, bitacora_id, nodo_id
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, id_tema, titulo, estado, descripcion,
bitacora_id, nodo_id, created_at, updated_at
SQL
result = @connection.exec_params(
query,
[id_tema, titulo, estado, descripcion, bitacora_id, nodo_id]
)
result.first
end
# Lista temas con filtros
#
# @param bitacora_id [Integer, nil] Filtrar por bitácora
# @param nodo_id [Integer, nil] Filtrar por nodo
# @param estado [String, nil] Filtrar por estado
# @param limit [Integer] Límite de resultados
# @return [Array<Hash>] Lista de temas
def list_temas(bitacora_id: nil, nodo_id: nil, estado: nil, limit: 100)
conditions = []
params = []
param_index = 1
if bitacora_id
conditions << "bitacora_id = $#{param_index}"
params << bitacora_id
param_index += 1
end
if nodo_id
conditions << "nodo_id = $#{param_index}"
params << nodo_id
param_index += 1
end
if estado
conditions << "estado = $#{param_index}"
params << estado
param_index += 1
end
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
query = <<~SQL
SELECT id, id_tema, titulo, estado, descripcion,
bitacora_id, nodo_id, created_at, updated_at
FROM #{SCHEMA}.#{TABLES[:temas]}
#{where_clause}
ORDER BY id_tema
LIMIT $#{param_index}
SQL
params << limit
result = @connection.exec_params(query, params)
result.map(&:to_h)
end
# MÉTODOS DE UTILIDAD
# Obtiene estadísticas del sistema
#
# @return [Hash] Estadísticas de todas las tablas
def statistics
return {} unless connection_test?
stats = {}
TABLES.each_key do |table|
query = "SELECT COUNT(*) as total FROM #{SCHEMA}.#{TABLES[table]}"
result = @connection.exec(query)
stats[table] = result.first['total'].to_i
end
# Estadísticas adicionales
query = <<~SQL
SELECT
COUNT(DISTINCT nodo_id) as nodos_con_entradas,
COUNT(DISTINCT DATE(created_at)) as dias_con_actividad,
COUNT(CASE WHEN es_ia THEN 1 END) as entradas_ia,
COUNT(CASE WHEN estado = '✅' THEN 1 END) as entradas_completadas
FROM #{SCHEMA}.#{TABLES[:entradas]}
SQL
result = @connection.exec(query)
extra_stats = result.first
stats.merge(
nodos_con_entradas: extra_stats['nodos_con_entradas'].to_i,
dias_con_actividad: extra_stats['dias_con_actividad'].to_i,
entradas_ia: extra_stats['entradas_ia'].to_i,
entradas_completadas: extra_stats['entradas_completadas'].to_i
)
end
# Ejecuta una consulta SQL personalizada
#
# @param sql [String] Consulta SQL
# @param params [Array] Parámetros para la consulta preparada
# @return [PG::Result] Resultado de la consulta
def execute(sql, params = [])
@connection.exec_params(sql, params)
end
# Verifica si existe una bitácora para hoy
#
# @return [Boolean] true si existe bitácora para hoy
def bitacora_hoy_existe?
fecha_hoy = Date.today.to_s
query = "SELECT 1 FROM #{SCHEMA}.#{TABLES[:bitacoras]} WHERE fecha = $1 LIMIT 1"
result = @connection.exec_params(query, [fecha_hoy])
!result.ntuples.zero?
end
# Crea bitácora para hoy si no existe
#
# @param resumen [String, nil] Resumen opcional
# @return [Hash, nil] Bitácora creada o nil si ya existía
def crear_bitacora_hoy(resumen: nil)
return nil if bitacora_hoy_existe?
create_bitacora(fecha: Date.today, resumen: resumen)
end
# ==========================================
# PROYECTOS, FASES E HITOS (Planes Sistemáticos)
# ==========================================
def find_proyecto_by_codigo(codigo)
q = "SELECT * FROM bitacoras.proyectos WHERE codigo = $1"
res = execute(q, [codigo.upcase])
return nil if res.ntuples.zero?
res.first.to_h
end
def list_hitos(proyecto_codigo)
proyecto = find_proyecto_by_codigo(proyecto_codigo)
raise StandardError, "Proyecto no encontrado: #{proyecto_codigo}" unless proyecto
q = <<~SQL
SELECT f.numero as fase_num, f.nombre as fase_nombre,
h.id, h.id_hito, h.titulo, h.estado, h.horas_presencial, h.horas_remoto
FROM bitacoras.fases f
JOIN bitacoras.hitos h ON h.fase_id = f.id
WHERE f.proyecto_id = $1
ORDER BY f.numero, h.id_hito
SQL
res = execute(q, [proyecto['id']])
res.map(&:to_h)
end
def rename_hito_id(proyecto_codigo, id_actual, id_nuevo)
q_busqueda = <<~SQL
SELECT h.id, h.titulo
FROM bitacoras.hitos h
JOIN bitacoras.fases f ON h.fase_id = f.id
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
WHERE p.codigo = $1 AND h.id_hito = $2
SQL
res = execute(q_busqueda, [proyecto_codigo.upcase, id_actual])
raise StandardError, "Hito #{id_actual} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
hito = res.first
q_update = "UPDATE bitacoras.hitos SET id_hito = $1 WHERE id = $2 RETURNING titulo"
res_update = execute(q_update, [id_nuevo, hito['id']])
res_update.first
end
def update_hito(proyecto_codigo, id_hito, estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil)
q_busqueda = <<~SQL
SELECT h.id, h.horas_presencial, h.horas_remoto, h.estado, h.descripcion
FROM bitacoras.hitos h
JOIN bitacoras.fases f ON h.fase_id = f.id
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
WHERE p.codigo = $1 AND h.id_hito = $2
SQL
res = execute(q_busqueda, [proyecto_codigo.upcase, id_hito])
raise StandardError, "Hito #{id_hito} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
hito = res.first
updates = []
params = []
param_count = 1
if estado
updates << "estado = $#{param_count}"
params << estado
param_count += 1
end
unless horas_presencial.nil?
updates << "horas_presencial = $#{param_count}"
params << horas_presencial
param_count += 1
end
unless horas_remoto.nil?
updates << "horas_remoto = $#{param_count}"
params << horas_remoto
param_count += 1
end
if descripcion
updates << "descripcion = $#{param_count}"
params << descripcion
param_count += 1
end
return hito if updates.empty?
updates << "updated_at = NOW()"
params << hito['id']
query = "UPDATE bitacoras.hitos SET #{updates.join(', ')} WHERE id = $#{param_count} RETURNING *"
resultado = execute(query, params)
resultado.first.to_h
end
private
# Ruta por defecto al archivo de configuración
def default_config_path
File.expand_path('../../config/database.yml', __dir__)
end
# Logger por defecto (ADN.logger si está disponible)
def default_logger
# Intentar usar ADN.logger si el módulo ADN está cargado
if defined?(ADN) && ADN.respond_to?(:logger)
ADN.logger
else
# Logger simple como fallback
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
def warn(msg); puts "[WARN] #{msg}"; end
end.new
end
end
# Parsea un tiempo a formato HH:MM:SS
#
# @param time [String, Time, nil] Tiempo a parsear
# @return [String, nil] Tiempo en formato HH:MM:SS o nil
def parse_time(time)
return nil if time.nil?
case time
when Time
time.strftime('%H:%M:%S')
when String
# Intentar parsear varios formatos
if time.match?(/^\d{1,2}:\d{2}$/) # HH:MM
"#{time}:00"
elsif time.match?(/^\d{1,2}:\d{2}:\d{2}$/) # HH:MM:SS
time
else
raise ArgumentError, "Formato de tiempo inválido: #{time}. Use HH:MM o HH:MM:SS"
end
else
raise ArgumentError, "Tiempo debe ser String o Time, recibido: #{time.class}"
end
end
# Convierte un resultado PG a Hash
def result_to_hash(result)
return {} if result.ntuples.zero?
result.first.to_h
end
end
end
+772
View File
@@ -0,0 +1,772 @@
# frozen_string_literal: true
# adn/tools/db/core/buscador_bd.rb — Sistema de búsqueda y filtrado para dtic-BITACORAs
# ================================================================================
# Esta clase proporciona capacidades avanzadas de búsqueda y filtrado para la
# base de datos dtic-BITACORAs. Implementa:
# • Búsqueda full-text con PostgreSQL tsvector/tsquery
# • Filtrado por múltiples criterios (estado, modo, fecha, etc.)
# • Búsqueda parcial y exacta en campos de texto
# • Paginación y ordenamiento de resultados
# • Exportación en múltiples formatos (texto, JSON, CSV)
#
# Uso básico:
# buscador = BitacorasDB::BuscadorBD.new
# resultados = buscador.buscar_entradas(texto: "reunión", estado: "✅", limite: 10)
# estadisticas = buscador.estadisticas_busquedas(periodo: "7dias")
#
# Integración con BitacoraDB existente:
# BitacorasDB::BitacoraDB.with_connection do |db|
# buscador = BitacorasDB::BuscadorBD.new(db.connection)
# resultados = buscador.buscar_entradas(texto: "dashboard")
# end
#
# Comandos CLI asociados:
# • adn db buscar:entradas [TEXTO] [OPCIONES]
# • adn db filtrar:entradas [--estado ESTADO] [--modo MODO] [--fecha DESDE-HASTA]
# • adn db buscar:nodos [TEXTO] [--tipo TIPO]
# • adn db buscar:temas [TEXTO] [--nodo NODO]
# • adn db estadisticas:busquedas [--periodo PERIODO]
require 'pg'
require 'json'
require 'csv'
require 'date'
module BitacorasDB
class BuscadorBD
# Configuración por defecto
DEFAULT_LIMIT = 100
DEFAULT_OFFSET = 0
DEFAULT_ORDER = 'created_at DESC'
# Campos de búsqueda full-text por tabla
FULL_TEXT_FIELDS = {
entradas: ['descripcion'],
temas: ['descripcion'],
nodos: ['descripcion'],
bitacoras: ['resumen']
}.freeze
# Campos de filtrado por tabla
FILTERABLE_FIELDS = {
entradas: ['estado', 'modo', 'nodo_id', 'tema_id', 'bitacora_id', 'es_ia'],
temas: ['estado', 'modo', 'nodo_id', 'bitacora_id'],
nodos: ['tipo', 'activo'],
bitacoras: [] # Filtrado principalmente por fecha
}.freeze
# Rangos de fecha/hora permitidos
DATE_RANGE_FIELDS = ['fecha', 'created_at', 'updated_at']
TIME_RANGE_FIELDS = ['inicio', 'fin']
# Inicializa el buscador con una conexión PostgreSQL
#
# @param connection [PG::Connection] Conexión activa a PostgreSQL
# @param logger [Object] Instancia de logger (opcional)
def initialize(connection, logger = nil)
@conn = connection
@logger = logger
@query_count = 0
@query_times = []
end
# Búsqueda principal en entradas con múltiples criterios
#
# @param texto [String, nil] Texto para búsqueda full-text
# @param estado [String, nil] Estado de la entrada (⏳, ✅, ❌, ⚠️, ⏸️)
# @param modo [String, nil] Modo de trabajo (P, R)
# @param nodo_id [Integer, nil] ID del nodo
# @param tema_id [Integer, nil] ID del tema
# @param bitacora_id [Integer, nil] ID de la bitácora
# @param fecha_desde [Date, String, nil] Fecha inicial
# @param fecha_hasta [Date, String, nil] Fecha final
# @param inicio_desde [Time, String, nil] Hora inicial desde
# @param inicio_hasta [Time, String, nil] Hora inicial hasta
# @param es_ia [Boolean, nil] Entradas generadas por IA
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento para paginación
# @param orden [String] Campo y dirección de ordenamiento
# @param formato [Symbol] Formato de salida (:hash, :json, :csv)
#
# @return [Array<Hash>, String] Resultados en formato especificado
def buscar_entradas(
texto: nil,
estado: nil,
modo: nil,
nodo_id: nil,
tema_id: nil,
bitacora_id: nil,
fecha_desde: nil,
fecha_hasta: nil,
inicio_desde: nil,
inicio_hasta: nil,
es_ia: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
orden: DEFAULT_ORDER,
formato: :hash
)
condiciones = []
parametros = []
# Búsqueda full-text
if texto && !texto.strip.empty?
condiciones << build_full_text_condition('entradas', texto, parametros)
end
# Filtros simples
if estado
condiciones << add_simple_filter('estado', estado, parametros)
end
if modo
condiciones << add_simple_filter('modo', modo, parametros)
end
if nodo_id
condiciones << add_simple_filter('nodo_id', nodo_id, parametros)
end
if tema_id
condiciones << add_simple_filter('tema_id', tema_id, parametros)
end
if bitacora_id
condiciones << add_simple_filter('bitacora_id', bitacora_id, parametros)
end
if !es_ia.nil?
condiciones << add_simple_filter('es_ia', es_ia, parametros)
end
# Filtros de rango de fecha
if fecha_desde || fecha_hasta
condiciones << build_date_range_condition('created_at', fecha_desde, fecha_hasta, parametros)
end
# Filtros de rango de hora
if inicio_desde || inicio_hasta
condiciones << build_time_range_condition('inicio', inicio_desde, inicio_hasta, parametros)
end
# Construir consulta SQL
sql = build_entradas_query(condiciones, orden, limite, offset, parametros.size)
# Ejecutar consulta
resultados = execute_query(sql, parametros)
# Formatear resultados
format_results(resultados, formato)
end
# Búsqueda en nodos con filtros
#
# @param texto [String, nil] Texto para búsqueda
# @param tipo [String, nil] Tipo de nodo (servidor, vm, pc, etc.)
# @param activo [Boolean, nil] Estado activo/inactivo
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @param orden [String] Ordenamiento
# @param formato [Symbol] Formato de salida
#
# @return [Array<Hash>, String] Resultados
def buscar_nodos(
texto: nil,
tipo: nil,
activo: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
orden: 'nombre ASC',
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda en nombre y descripción
if texto && !texto.strip.empty?
condiciones << "(nombre ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
parametros << "%#{texto}%"
contador += 1
end
# Filtros
condiciones << add_simple_filter('tipo', tipo, parametros, contador) if tipo
contador += 1 if tipo
condiciones << add_simple_filter('activo', activo, parametros, contador) if !activo.nil?
contador += 1 if !activo.nil?
# Construir consulta
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM bitacoras.nodos
#{where_clause}
ORDER BY #{orden}
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
parametros << limite
parametros << offset
resultados = execute_query(sql, parametros)
format_results(resultados, formato)
end
# Búsqueda en temas con filtros
#
# @param texto [String, nil] Texto para búsqueda
# @param estado [String, nil] Estado del tema
# @param nodo_id [Integer, nil] ID del nodo
# @param bitacora_id [Integer, nil] ID de la bitácora
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @param orden [String] Ordenamiento
# @param formato [Symbol] Formato de salida
#
# @return [Array<Hash>, String] Resultados
def buscar_temas(
texto: nil,
estado: nil,
nodo_id: nil,
bitacora_id: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
orden: 'titulo ASC',
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda full-text en descripción
if texto && !texto.strip.empty?
condiciones << build_full_text_condition('temas', texto, parametros, contador)
contador += 1
end
# Filtros
condiciones << add_simple_filter('estado', estado, parametros, contador) if estado
contador += 1 if estado
condiciones << add_simple_filter('nodo_id', nodo_id, parametros, contador) if nodo_id
contador += 1 if nodo_id
condiciones << add_simple_filter('bitacora_id', bitacora_id, parametros, contador) if bitacora_id
contador += 1 if bitacora_id
# Construir consulta
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT id, id_tema, titulo, estado, descripcion,
nodo_id, bitacora_id, created_at, updated_at
FROM bitacoras.temas
#{where_clause}
ORDER BY #{orden}
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
parametros << limite
parametros << offset
resultados = execute_query(sql, parametros)
format_results(resultados, formato)
end
# Búsqueda en bitácoras
#
# @param texto [String, nil] Texto para búsqueda en resumen
# @param fecha_desde [Date, String, nil] Fecha inicial
# @param fecha_hasta [Date, String, nil] Fecha final
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @param orden [String] Ordenamiento
# @param formato [Symbol] Formato de salida
#
# @return [Array<Hash>, String] Resultados
def buscar_bitacoras(
texto: nil,
fecha_desde: nil,
fecha_hasta: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
orden: 'fecha DESC',
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda en resumen
if texto && !texto.strip.empty?
condiciones << "resumen ILIKE $#{contador}"
parametros << "%#{texto}%"
contador += 1
end
# Filtro por rango de fecha
if fecha_desde || fecha_hasta
condiciones << build_date_range_condition('fecha', fecha_desde, fecha_hasta, parametros, contador)
contador += (fecha_desde ? 1 : 0) + (fecha_hasta ? 1 : 0)
end
# Construir consulta
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT id, fecha, resumen, created_at, updated_at
FROM bitacoras.bitacoras
#{where_clause}
ORDER BY #{orden}
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
parametros << limite
parametros << offset
resultados = execute_query(sql, parametros)
format_results(resultados, formato)
end
# Búsqueda avanzada con joins entre tablas
#
# @param texto [String] Texto para búsqueda en múltiples tablas
# @param tipos [Array<Symbol>] Tipos de entidades a buscar (:entradas, :temas, :nodos, :bitacoras)
# @param limite_por_tipo [Integer] Límite por tipo de entidad
# @param formato [Symbol] Formato de salida
#
# @return [Hash] Resultados agrupados por tipo
def buscar_avanzado(
texto:,
tipos: [:entradas, :temas, :nodos, :bitacoras],
limite_por_tipo: 10,
formato: :hash
)
resultados = {}
tipos.each do |tipo|
case tipo
when :entradas
resultados[:entradas] = buscar_entradas(texto: texto, limite: limite_por_tipo, formato: formato)
when :temas
resultados[:temas] = buscar_temas(texto: texto, limite: limite_por_tipo, formato: formato)
when :nodos
resultados[:nodos] = buscar_nodos(texto: texto, limite: limite_por_tipo, formato: formato)
when :bitacoras
resultados[:bitacoras] = buscar_bitacoras(texto: texto, limite: limite_por_tipo, formato: formato)
end
end
# Calcular estadísticas
resultados[:estadisticas] = {
total_resultados: resultados.values.sum { |r| r.is_a?(Array) ? r.size : 0 },
tipos_buscados: tipos,
texto_buscado: texto,
timestamp: Time.now.iso8601
}
resultados
end
# Estadísticas de uso del sistema de búsqueda
#
# @param periodo [String] Período para estadísticas (7dias, 30dias, hoy, semana, mes)
# @param formato [Symbol] Formato de salida
#
# @return [Hash, String] Estadísticas
def estadisticas_busquedas(periodo: '7dias', formato: :hash)
fecha_desde = calculate_period_start(periodo)
# En un sistema real, aquí se consultaría una tabla de logs de búsquedas
# Por ahora, devolvemos estadísticas simuladas basadas en el análisis del esquema
estadisticas = {
periodo: periodo,
fecha_desde: fecha_desde,
fecha_hasta: Date.today.to_s,
total_busquedas: @query_count,
tiempo_promedio: @query_times.empty? ? 0 : @query_times.sum / @query_times.size,
tipos_mas_buscados: ['entradas', 'temas', 'nodos'],
campos_filtro_populares: ['estado', 'fecha', 'modo'],
recomendaciones: [
'Considerar agregar índice full-text en entradas.descripcion',
'Implementar cache de consultas frecuentes',
'Agregar logging de búsquedas para análisis posterior'
]
}
format_single_result(estadisticas, formato)
end
# Crear índices recomendados para optimización
#
# @return [Hash] Resultado de la operación
def crear_indices_recomendados
indices = [
# Índices full-text
"CREATE INDEX IF NOT EXISTS idx_entradas_descripcion_fts ON bitacoras.entradas USING gin(to_tsvector('spanish', descripcion))",
"CREATE INDEX IF NOT EXISTS idx_temas_descripcion_fts ON bitacoras.temas USING gin(to_tsvector('spanish', descripcion))",
# Índices para filtrado frecuente
"CREATE INDEX IF NOT EXISTS idx_entradas_estado ON bitacoras.entradas(estado)",
"CREATE INDEX IF NOT EXISTS idx_entradas_modo ON bitacoras.entradas(modo)",
"CREATE INDEX IF NOT EXISTS idx_entradas_created_at ON bitacoras.entradas(created_at)",
"CREATE INDEX IF NOT EXISTS idx_bitacoras_fecha ON bitacoras.bitacoras(fecha)",
"CREATE INDEX IF NOT EXISTS idx_nodos_tipo ON bitacoras.nodos(tipo)",
# Índices para joins
"CREATE INDEX IF NOT EXISTS idx_entradas_nodo_id ON bitacoras.entradas(nodo_id)",
"CREATE INDEX IF NOT EXISTS idx_entradas_tema_id ON bitacoras.entradas(tema_id)",
"CREATE INDEX IF NOT EXISTS idx_entradas_bitacora_id ON bitacoras.entradas(bitacora_id)",
"CREATE INDEX IF NOT EXISTS idx_temas_nodo_id ON bitacoras.temas(nodo_id)"
]
resultados = []
indices.each do |sql|
begin
start_time = Time.now
@conn.exec(sql)
elapsed = Time.now - start_time
resultados << { indice: sql, estado: 'creado', tiempo: elapsed.round(3) }
@logger&.info("Índice creado: #{sql.split(' ON ').first}") if @logger
rescue PG::Error => e
resultados << { indice: sql, estado: 'error', error: e.message }
@logger&.error("Error creando índice: #{e.message}") if @logger
end
end
{
total_indices: indices.size,
creados: resultados.count { |r| r[:estado] == 'creado' },
errores: resultados.count { |r| r[:estado] == 'error' },
detalles: resultados,
timestamp: Time.now.iso8601
}
end
# Métricas de rendimiento del sistema de búsqueda
#
# @return [Hash] Métricas de rendimiento
def metricas_rendimiento
# Consultar estadísticas de PostgreSQL
stats = {}
begin
# Tamaño de tablas
size_query = <<-SQL
SELECT
table_name,
pg_size_pretty(pg_total_relation_size('bitacoras.' || table_name)) as tamaño_total,
pg_size_pretty(pg_relation_size('bitacoras.' || table_name)) as tamaño_tabla,
pg_size_pretty(pg_indexes_size('bitacoras.' || table_name)) as tamaño_indices,
n_live_tup as registros
FROM information_schema.tables t
JOIN pg_stat_user_tables s ON t.table_name = s.relname
WHERE t.table_schema = 'bitacoras'
ORDER BY pg_total_relation_size('bitacoras.' || table_name) DESC
SQL
stats[:tamanos_tablas] = @conn.exec(size_query).to_a
# Índices más usados
index_query = <<-SQL
SELECT
indexrelname as nombre_indice,
idx_scan as escaneos,
idx_tup_read as tuplas_leidas,
idx_tup_fetch as tuplas_recuperadas
FROM pg_stat_user_indexes
WHERE schemaname = 'bitacoras'
ORDER BY idx_scan DESC
LIMIT 10
SQL
stats[:indices_uso] = @conn.exec(index_query).to_a
# Consultas lentas (si está habilitado pg_stat_statements)
begin
slow_query = <<-SQL
SELECT
query,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
WHERE query LIKE '%bitacoras.%'
ORDER BY mean_time DESC
LIMIT 5
SQL
stats[:consultas_lentas] = @conn.exec(slow_query).to_a
rescue PG::Error
stats[:consultas_lentas] = []
end
rescue PG::Error => e
stats[:error] = e.message
end
# Métricas internas
stats[:metricas_internas] = {
total_consultas: @query_count,
tiempo_promedio: @query_times.empty? ? 0 : @query_times.sum / @query_times.size,
consultas_mas_lenta: @query_times.max,
consultas_mas_rapida: @query_times.min
}
stats
end
private
# Construir condición de búsqueda full-text
def build_full_text_condition(table, texto, parametros)
campos = FULL_TEXT_FIELDS[table.to_sym] || []
return "1=0" if campos.empty?
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
prefijo = table == 'entradas' ? 'e.' : ''
condiciones = campos.map do |campo|
"to_tsvector('spanish', #{prefijo}#{campo}) @@ plainto_tsquery('spanish', $#{parametros.size + 1})"
end
parametros << texto
"(#{condiciones.join(' OR ')})"
end
# Agregar filtro simple
def add_simple_filter(campo, valor, parametros)
parametros << valor
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
prefijo = campo.include?('.') ? '' : (campo == 'nodo_id' || campo == 'tema_id' || campo == 'bitacora_id' || campo == 'estado' || campo == 'modo' || campo == 'es_ia' ? 'e.' : '')
"#{prefijo}#{campo} = $#{parametros.size}"
end
# Construir condición de rango de fecha
def build_date_range_condition(campo, desde, hasta, parametros)
condiciones = []
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.' para created_at
prefijo = campo == 'created_at' ? 'e.' : ''
if desde
parametros << desde
condiciones << "#{prefijo}#{campo} >= $#{parametros.size}"
end
if hasta
parametros << hasta
condiciones << "#{prefijo}#{campo} <= $#{parametros.size}"
end
condiciones.any? ? "(#{condiciones.join(' AND ')})" : "1=1"
end
# Construir condición de rango de hora
def build_time_range_condition(campo, desde, hasta, parametros)
condiciones = []
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
prefijo = campo == 'inicio' ? 'e.' : ''
if desde
parametros << desde
condiciones << "#{prefijo}#{campo} >= $#{parametros.size}"
end
if hasta
parametros << hasta
condiciones << "#{prefijo}#{campo} <= $#{parametros.size}"
end
condiciones.any? ? "(#{condiciones.join(' AND ')})" : "1=1"
end
# Construir consulta SQL para entradas
def build_entradas_query(condiciones, orden, limite, offset, num_parametros)
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
<<-SQL
SELECT
e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
n.nombre as nodo_nombre, n.tipo as nodo_tipo,
t.titulo as tema_titulo, t.id_tema as tema_codigo,
b.fecha as bitacora_fecha
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
LEFT JOIN bitacoras.temas t ON e.tema_id = t.id
LEFT JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
#{where_clause}
ORDER BY #{orden}
LIMIT $#{num_parametros + 1} OFFSET $#{num_parametros + 2}
SQL
end
# Ejecutar consulta con medición de tiempo
def execute_query(sql, parametros)
start_time = Time.now
begin
resultado = @conn.exec_params(sql, parametros)
elapsed = Time.now - start_time
@query_count += 1
@query_times << elapsed
@logger&.debug("Consulta ejecutada en #{elapsed.round(3)}s: #{sql.split('LIMIT').first}...") if @logger
resultado
rescue PG::Error => e
@logger&.error("Error en consulta SQL: #{e.message}\nSQL: #{sql}") if @logger
raise
end
end
# Formatear resultados según formato solicitado
def format_results(resultados, formato)
case formato
when :hash
resultados.to_a
when :json
JSON.pretty_generate(resultados.to_a)
when :csv
return "" if resultados.ntuples == 0
CSV.generate do |csv|
# Encabezados
csv << resultados.fields
# Datos
resultados.each do |row|
csv << row.values
end
end
when :texto
format_results_as_text(resultados)
else
resultados.to_a
end
end
# Formatear resultados como texto legible
def format_results_as_text(resultados)
return "No se encontraron resultados" if resultados.ntuples == 0
output = []
output << "📊 Resultados encontrados: #{resultados.ntuples}"
output << "=" * 60
resultados.each_with_index do |row, index|
output << "\n#{index + 1}. #{row['descripcion'] || row['nombre'] || row['titulo'] || 'Sin título'}"
row.each do |key, value|
next if value.nil? || value.to_s.empty?
next if key == 'descripcion' || key == 'nombre' || key == 'titulo'
# Formatear según el tipo de campo
formatted_key = key.gsub('_', ' ').capitalize
formatted_value = case key
when 'created_at', 'updated_at', 'fecha'
DateTime.parse(value.to_s).strftime('%Y-%m-%d %H:%M')
when 'inicio', 'fin'
value.to_s[0..4] # HH:MM
when 'es_ia'
value == 't' ? 'Sí' : 'No'
when 'estado'
"#{value} #{estado_emoji(value)}"
else
value.to_s
end
output << "#{formatted_key}: #{formatted_value}"
end
end
output.join("\n")
end
# Formatear resultado único
def format_single_result(resultado, formato)
case formato
when :hash
resultado
when :json
JSON.pretty_generate(resultado)
when :texto
format_single_result_as_text(resultado)
else
resultado
end
end
# Formatear resultado único como texto
def format_single_result_as_text(resultado)
output = []
output << "📈 #{resultado[:titulo] || 'Estadísticas'}"
output << "=" * 50
resultado.each do |key, value|
next if key == :titulo
formatted_key = key.to_s.gsub('_', ' ').capitalize
if value.is_a?(Hash)
output << "\n#{formatted_key}:"
value.each do |subkey, subvalue|
output << "#{subkey}: #{subvalue}"
end
elsif value.is_a?(Array)
output << "\n#{formatted_key} (#{value.size}):"
value.first(5).each_with_index do |item, idx|
output << " #{idx + 1}. #{item}"
end
output << " ..." if value.size > 5
else
output << "#{formatted_key}: #{value}"
end
end
output.join("\n")
end
# Calcular fecha de inicio según período
def calculate_period_start(periodo)
case periodo.to_s.downcase
when 'hoy'
Date.today
when 'ayer'
Date.today - 1
when '7dias', 'semana'
Date.today - 7
when '30dias', 'mes'
Date.today - 30
when '90dias', 'trimestre'
Date.today - 90
when '365dias', 'año'
Date.today - 365
else
Date.today - 7 # Por defecto 7 días
end
end
# Emoji para estado
def estado_emoji(estado)
case estado
when '⏳' then '⏳'
when '✅' then '✅'
when '❌' then '❌'
when '⚠️' then '⚠️'
when '⏸️' then '⏸️'
else estado
end
end
end
end
+586
View File
@@ -0,0 +1,586 @@
# frozen_string_literal: true
# adn/tools/db/core/buscador_bd_simple.rb — Versión simplificada del sistema de búsqueda
# ================================================================================
# Versión simplificada y funcional del sistema de búsqueda para dtic-BITACORAs
# Esta versión evita problemas complejos de parámetros y se enfoca en funcionalidad básica
require 'pg'
require 'json'
require 'csv'
require 'date'
module BitacorasDB
class BuscadorBDSimple
# Configuración por defecto
DEFAULT_LIMIT = 50
DEFAULT_OFFSET = 0
# Inicializa el buscador con una conexión PostgreSQL
#
# @param connection [PG::Connection] Conexión activa a PostgreSQL
# @param logger [Object] Instancia de logger (opcional)
def initialize(connection, logger = nil)
@conn = connection
@logger = logger
end
# Búsqueda simple en entradas
#
# @param texto [String, nil] Texto para búsqueda (LIKE)
# @param estado [String, nil] Estado de la entrada (⏳, ✅, ❌, ⚠️, ⏸️)
# @param modo [String, nil] Modo de trabajo (P, R)
# @param nodo_id [Integer, nil] ID del nodo
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento para paginación
# @param formato [Symbol] Formato de salida (:hash, :json, :texto)
#
# @return [Array<Hash>, String] Resultados en formato especificado
def buscar_entradas_simple(
texto: nil,
estado: nil,
modo: nil,
nodo_id: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda por texto
if texto && !texto.strip.empty?
condiciones << "e.descripcion ILIKE $#{contador}"
parametros << "%#{texto}%"
contador += 1
end
# Filtros
if estado
condiciones << "e.estado = $#{contador}"
parametros << estado
contador += 1
end
if modo
condiciones << "e.modo = $#{contador}"
parametros << modo
contador += 1
end
if nodo_id
condiciones << "e.nodo_id = $#{contador}"
parametros << nodo_id
contador += 1
end
# Construir consulta SQL
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT
e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
n.nombre as nodo_nombre, n.tipo as nodo_tipo,
t.titulo as tema_titulo,
b.fecha as bitacora_fecha
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
LEFT JOIN bitacoras.temas t ON e.tema_id = t.id
LEFT JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
#{where_clause}
ORDER BY e.created_at DESC
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
# Agregar límite y offset a parámetros
parametros << limite
parametros << offset
# Ejecutar consulta
resultados = @conn.exec_params(sql, parametros)
# Formatear resultados
case formato
when :hash
resultados.to_a
when :json
JSON.pretty_generate(resultados.to_a)
when :texto
formatar_resultados_texto(resultados)
else
resultados.to_a
end
end
# Búsqueda simple en nodos
#
# @param texto [String, nil] Texto para búsqueda en nombre o descripción
# @param tipo [String, nil] Tipo de nodo (servidor, vm, pc, etc.)
# @param activo [Boolean, nil] Estado activo/inactivo
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @param formato [Symbol] Formato de salida
#
# @return [Array<Hash>, String] Resultados
def buscar_nodos_simple(
texto: nil,
tipo: nil,
activo: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda por texto
if texto && !texto.strip.empty?
condiciones << "(nombre ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
parametros << "%#{texto}%"
contador += 1
end
# Filtros
if tipo
condiciones << "tipo = $#{contador}"
parametros << tipo
contador += 1
end
if !activo.nil?
condiciones << "activo = $#{contador}"
parametros << activo
contador += 1
end
# Construir consulta
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
FROM bitacoras.nodos
#{where_clause}
ORDER BY nombre ASC
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
parametros << limite
parametros << offset
resultados = @conn.exec_params(sql, parametros)
case formato
when :hash
resultados.to_a
when :json
JSON.pretty_generate(resultados.to_a)
when :texto
formatar_resultados_texto(resultados)
else
resultados.to_a
end
end
# Búsqueda simple en temas
#
# @param texto [String, nil] Texto para búsqueda en título o descripción
# @param estado [String, nil] Estado del tema
# @param nodo_id [Integer, nil] ID del nodo
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @param formato [Symbol] Formato de salida
#
# @return [Array<Hash>, String] Resultados
def buscar_temas_simple(
texto: nil,
estado: nil,
nodo_id: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda por texto
if texto && !texto.strip.empty?
condiciones << "(titulo ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
parametros << "%#{texto}%"
contador += 1
end
# Filtros
if estado
condiciones << "estado = $#{contador}"
parametros << estado
contador += 1
end
if nodo_id
condiciones << "nodo_id = $#{contador}"
parametros << nodo_id
contador += 1
end
# Construir consulta
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT id, id_tema, titulo, estado, descripcion,
nodo_id, bitacora_id, created_at, updated_at
FROM bitacoras.temas
#{where_clause}
ORDER BY titulo ASC
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
parametros << limite
parametros << offset
resultados = @conn.exec_params(sql, parametros)
case formato
when :hash
resultados.to_a
when :json
JSON.pretty_generate(resultados.to_a)
when :texto
formatar_resultados_texto(resultados)
else
resultados.to_a
end
end
# Búsqueda simple en bitácoras
#
# @param texto [String, nil] Texto para búsqueda en resumen
# @param fecha_desde [Date, String, nil] Fecha inicial
# @param fecha_hasta [Date, String, nil] Fecha final
# @param limite [Integer] Límite de resultados
# @param offset [Integer] Desplazamiento
# @param formato [Symbol] Formato de salida
#
# @return [Array<Hash>, String] Resultados
def buscar_bitacoras_simple(
texto: nil,
fecha_desde: nil,
fecha_hasta: nil,
limite: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
formato: :hash
)
condiciones = []
parametros = []
contador = 1
# Búsqueda por texto
if texto && !texto.strip.empty?
condiciones << "resumen ILIKE $#{contador}"
parametros << "%#{texto}%"
contador += 1
end
# Filtro por fecha
if fecha_desde
condiciones << "fecha >= $#{contador}"
parametros << fecha_desde
contador += 1
end
if fecha_hasta
condiciones << "fecha <= $#{contador}"
parametros << fecha_hasta
contador += 1
end
# Construir consulta
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
sql = <<-SQL
SELECT id, fecha, resumen, created_at, updated_at
FROM bitacoras.bitacoras
#{where_clause}
ORDER BY fecha DESC
LIMIT $#{contador} OFFSET $#{contador + 1}
SQL
parametros << limite
parametros << offset
resultados = @conn.exec_params(sql, parametros)
case formato
when :hash
resultados.to_a
when :json
JSON.pretty_generate(resultados.to_a)
when :texto
formatar_resultados_texto(resultados)
else
resultados.to_a
end
end
# Búsqueda avanzada simple (múltiples tablas)
#
# @param texto [String] Texto para búsqueda en múltiples tablas
# @param limite_por_tipo [Integer] Límite por tipo de entidad
#
# @return [Hash] Resultados agrupados por tipo
def buscar_avanzado_simple(texto:, limite_por_tipo: 5)
resultados = {}
# Buscar en entradas
begin
resultados[:entradas] = buscar_entradas_simple(
texto: texto,
limite: limite_por_tipo,
formato: :hash
)
rescue => e
resultados[:entradas_error] = e.message
end
# Buscar en nodos
begin
resultados[:nodos] = buscar_nodos_simple(
texto: texto,
limite: limite_por_tipo,
formato: :hash
)
rescue => e
resultados[:nodos_error] = e.message
end
# Buscar en temas
begin
resultados[:temas] = buscar_temas_simple(
texto: texto,
limite: limite_por_tipo,
formato: :hash
)
rescue => e
resultados[:temas_error] = e.message
end
# Buscar en bitácoras
begin
resultados[:bitacoras] = buscar_bitacoras_simple(
texto: texto,
limite: limite_por_tipo,
formato: :hash
)
rescue => e
resultados[:bitacoras_error] = e.message
end
# Calcular estadísticas
total = 0
[:entradas, :nodos, :temas, :bitacoras].each do |tipo|
if resultados[tipo].is_a?(Array)
total += resultados[tipo].size
end
end
resultados[:estadisticas] = {
total_resultados: total,
texto_buscado: texto,
timestamp: Time.now.iso8601
}
resultados
end
# Crear índices básicos para optimización
#
# @return [Hash] Resultado de la operación
def crear_indices_basicos
indices = [
# Índices para búsqueda por texto
"CREATE INDEX IF NOT EXISTS idx_entradas_descripcion ON bitacoras.entradas(descripcion)",
"CREATE INDEX IF NOT EXISTS idx_nodos_nombre ON bitacoras.nodos(nombre)",
"CREATE INDEX IF NOT EXISTS idx_temas_titulo ON bitacoras.temas(titulo)",
# Índices para filtrado frecuente
"CREATE INDEX IF NOT EXISTS idx_entradas_estado ON bitacoras.entradas(estado)",
"CREATE INDEX IF NOT EXISTS idx_entradas_created_at ON bitacoras.entradas(created_at)",
"CREATE INDEX IF NOT EXISTS idx_bitacoras_fecha ON bitacoras.bitacoras(fecha)",
# Índices para joins
"CREATE INDEX IF NOT EXISTS idx_entradas_nodo_id ON bitacoras.entradas(nodo_id)",
"CREATE INDEX IF NOT EXISTS idx_entradas_tema_id ON bitacoras.entradas(tema_id)"
]
resultados = []
indices.each do |sql|
begin
@conn.exec(sql)
resultados << { indice: sql.split(' ON ').first, estado: 'creado' }
@logger&.info("Índice creado: #{sql.split(' ON ').first}") if @logger
rescue PG::Error => e
resultados << { indice: sql.split(' ON ').first, estado: 'error', error: e.message }
@logger&.error("Error creando índice: #{e.message}") if @logger
end
end
{
total_indices: indices.size,
creados: resultados.count { |r| r[:estado] == 'creado' },
errores: resultados.count { |r| r[:estado] == 'error' },
detalles: resultados
}
end
# Obtener estadísticas básicas del sistema
#
# @return [Hash] Estadísticas
def estadisticas_basicas
estadisticas = {}
# Conteo por tabla
tablas = ['nodos', 'bitacoras', 'temas', 'entradas']
tablas.each do |tabla|
begin
count = @conn.exec("SELECT COUNT(*) as total FROM bitacoras.#{tabla}")[0]['total']
estadisticas[tabla.to_sym] = count.to_i
rescue => e
estadisticas["#{tabla}_error".to_sym] = e.message
end
end
# Entradas por estado
begin
query = "SELECT estado, COUNT(*) as total FROM bitacoras.entradas GROUP BY estado ORDER BY total DESC"
resultados = @conn.exec(query)
estadisticas[:entradas_por_estado] = resultados.to_a
rescue => e
estadisticas[:entradas_por_estado_error] = e.message
end
# Entradas por modo
begin
query = "SELECT modo, COUNT(*) as total FROM bitacoras.entradas GROUP BY modo ORDER BY total DESC"
resultados = @conn.exec(query)
estadisticas[:entradas_por_modo] = resultados.to_a
rescue => e
estadisticas[:entradas_por_modo_error] = e.message
end
# Entradas generadas por IA
begin
query = "SELECT COUNT(*) as total FROM bitacoras.entradas WHERE es_ia = true"
count = @conn.exec(query)[0]['total']
estadisticas[:entradas_ia] = count.to_i
total_entradas = estadisticas[:entradas] || 0
if total_entradas > 0
porcentaje = (count.to_f / total_entradas * 100).round(2)
estadisticas[:porcentaje_ia] = porcentaje
end
rescue => e
estadisticas[:entradas_ia_error] = e.message
end
estadisticas[:timestamp] = Time.now.iso8601
estadisticas
end
private
# Formatear resultados como texto legible
def formatar_resultados_texto(resultados)
return "No se encontraron resultados" if resultados.ntuples == 0
output = []
output << "📊 Resultados encontrados: #{resultados.ntuples}"
output << "=" * 60
resultados.each_with_index do |row, index|
# Determinar título según la tabla
titulo = row['descripcion'] || row['nombre'] || row['titulo'] || row['resumen'] || "Sin título"
output << "\n#{index + 1}. #{titulo[0..80]}#{'...' if titulo.length > 80}"
output << " " + "-" * 40
# Mostrar campos relevantes
campos_mostrar = []
if row['estado']
emoji = case row['estado']
when '⏳' then '⏳'
when '✅' then '✅'
when '❌' then '❌'
when '⚠️' then '⚠️'
when '⏸️' then '⏸️'
else row['estado']
end
campos_mostrar << "Estado: #{emoji}"
end
if row['nodo_nombre']
campos_mostrar << "Nodo: #{row['nodo_nombre']}"
elsif row['nodo_id']
campos_mostrar << "Nodo ID: #{row['nodo_id']}"
end
if row['tema_titulo']
campos_mostrar << "Tema: #{row['tema_titulo']}"
end
if row['bitacora_fecha']
campos_mostrar << "Bitácora: #{row['bitacora_fecha']}"
end
if row['inicio']
hora = row['inicio'].to_s[0..4] # HH:MM
campos_mostrar << "Inicio: #{hora}"
end
if row['fin']
hora = row['fin'].to_s[0..4] # HH:MM
campos_mostrar << "Fin: #{hora}"
end
if row['created_at']
fecha = DateTime.parse(row['created_at'].to_s).strftime('%Y-%m-%d %H:%M')
campos_mostrar << "Creado: #{fecha}"
end
if row['fecha']
campos_mostrar << "Fecha: #{row['fecha']}"
end
if row['tipo']
campos_mostrar << "Tipo: #{row['tipo']}"
end
if row['activo']
estado = row['activo'] == 't' ? 'Activo' : 'Inactivo'
campos_mostrar << "Estado: #{estado}"
end
if row['es_ia']
es_ia = row['es_ia'] == 't' ? 'Sí' : 'No'
campos_mostrar << "Generado por IA: #{es_ia}"
end
# Mostrar campos (máximo 5 para no saturar)
campos_mostrar.first(5).each do |campo|
output << "#{campo}"
end
# Si hay más campos, mostrar indicador
if campos_mostrar.size > 5
output << " • ... (#{campos_mostrar.size - 5} campos más)"
end
end
output.join("\n")
end
end
end