[P2606] WebServer NGINX: plan actualizado, ficha nodo, HTTPS operativo
- P2606: Plan v1.1 EN EJECUCIÓN. Fases 1-3 completadas. - Ficha nodo srvv-nginx-rm.md. P2606 en adn/07_proyectos.md. - Incluye cambios acumulados de sesiones anteriores.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'pg'
|
||||
require 'json'
|
||||
|
||||
module ADN
|
||||
module AmbitosDB
|
||||
module BaseOperations
|
||||
# Listar ámbitos
|
||||
def list_ambitos(activo: nil)
|
||||
query = "SELECT a.*, p.nombre as parent_nombre,
|
||||
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as count_nodos
|
||||
FROM bitacoras.ambitos a
|
||||
LEFT JOIN bitacoras.ambitos p ON a.parent_id = p.id"
|
||||
params = []
|
||||
|
||||
unless activo.nil?
|
||||
query += " WHERE a.activo = $1"
|
||||
params << (activo ? 't' : 'f')
|
||||
end
|
||||
|
||||
query += " ORDER BY a.parent_id NULLS FIRST, a.nombre ASC"
|
||||
|
||||
res = execute(query, params)
|
||||
res.map do |r|
|
||||
r['activo'] = (r['activo'] == 't') if r.key?('activo')
|
||||
r
|
||||
end
|
||||
end
|
||||
|
||||
# Obtener información de un ámbito
|
||||
def get_ambito(id_or_nombre)
|
||||
query = "SELECT a.*, p.nombre as parent_nombre,
|
||||
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as count_nodos
|
||||
FROM bitacoras.ambitos a
|
||||
LEFT JOIN bitacoras.ambitos p ON a.parent_id = p.id
|
||||
WHERE a.id = $1 OR a.nombre = $2 LIMIT 1"
|
||||
|
||||
id = id_or_nombre.to_i > 0 ? id_or_nombre.to_i : 0
|
||||
|
||||
res = execute(query, [id, id_or_nombre.to_s])
|
||||
return nil if res.ntuples == 0
|
||||
|
||||
row = res[0]
|
||||
row['activo'] = (row['activo'] == 't') if row.key?('activo')
|
||||
row
|
||||
end
|
||||
|
||||
# Crear un nuevo ámbito
|
||||
def create_ambito(nombre:, descripcion: nil, parent_id: nil, activo: true)
|
||||
query = "INSERT INTO bitacoras.ambitos (nombre, descripcion, parent_id, activo)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *"
|
||||
params = [nombre, descripcion, parent_id, activo ? 't' : 'f']
|
||||
|
||||
res = execute(query, params)
|
||||
row = res[0]
|
||||
row['activo'] = (row['activo'] == 't') if row.key?('activo')
|
||||
row
|
||||
end
|
||||
|
||||
# Actualizar ámbito
|
||||
def update_ambito(id, campos = {})
|
||||
return nil if campos.empty?
|
||||
|
||||
set_parts = []
|
||||
params = []
|
||||
campos.each_with_index do |(k, v), i|
|
||||
set_parts << "#{k} = $#{i + 1}"
|
||||
params << (v.is_a?(TrueClass) || v.is_a?(FalseClass) ? (v ? 't' : 'f') : v)
|
||||
end
|
||||
|
||||
params << id
|
||||
query = "UPDATE bitacoras.ambitos SET #{set_parts.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $#{params.length} RETURNING *"
|
||||
|
||||
res = execute(query, params)
|
||||
return nil if res.ntuples == 0
|
||||
|
||||
row = res[0]
|
||||
row['activo'] = (row['activo'] == 't') if row.key?('activo')
|
||||
row
|
||||
end
|
||||
|
||||
# Eliminar ámbito
|
||||
def delete_ambito(id)
|
||||
query = "DELETE FROM bitacoras.ambitos WHERE id = $1 RETURNING *"
|
||||
res = execute(query, [id.to_i])
|
||||
res.ntuples > 0
|
||||
end
|
||||
|
||||
# Listar nodos de un ámbito
|
||||
def get_nodos_by_ambito(ambito_id)
|
||||
query = "SELECT * FROM bitacoras.nodos WHERE ambito_id = $1 ORDER BY nombre ASC"
|
||||
res = execute(query, [ambito_id.to_i])
|
||||
res.map do |r|
|
||||
r['activo'] = (r['activo'] == 't') if r.key?('activo')
|
||||
r
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,9 +30,13 @@ require 'yaml'
|
||||
require 'json'
|
||||
require 'date'
|
||||
require 'time'
|
||||
require_relative 'mejoras_sensor'
|
||||
require_relative 'ambito_db'
|
||||
|
||||
module BitacorasDB
|
||||
class BitacoraDB
|
||||
include ADN::AmbitosDB::BaseOperations
|
||||
|
||||
# Constantes para el esquema y tablas
|
||||
SCHEMA = 'bitacoras'
|
||||
TABLES = {
|
||||
@@ -60,6 +64,7 @@ module BitacorasDB
|
||||
@connection = nil
|
||||
@logger = logger || default_logger
|
||||
@connected = false
|
||||
@activo = ::ADN::MejorasSensor.activo?
|
||||
end
|
||||
|
||||
# Carga la configuración desde el archivo YAML
|
||||
@@ -197,18 +202,24 @@ module BitacorasDB
|
||||
#
|
||||
# @param fecha [Date, String] Fecha de la bitácora
|
||||
# @param resumen [String, nil] Resumen opcional
|
||||
# @param modo [String, nil] Modo de la jornada ('P', 'R', 'S')
|
||||
# @return [Hash] Bitácora creada con ID
|
||||
# @raise [PG::Error] Si ya existe bitácora para esa fecha
|
||||
def create_bitacora(fecha:, resumen: nil)
|
||||
def create_bitacora(fecha:, resumen: nil, modo: 'P')
|
||||
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
|
||||
modo ||= 'P'
|
||||
|
||||
unless MODOS_VALIDOS.include?(modo)
|
||||
raise ArgumentError, "Modo inválido. Válidos: #{MODOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, fecha, resumen, created_at, updated_at
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen, modo)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, fecha, resumen, modo, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [fecha.to_s, resumen])
|
||||
result = @connection.exec_params(query, [fecha.to_s, resumen, modo])
|
||||
result.first
|
||||
end
|
||||
|
||||
@@ -239,7 +250,7 @@ module BitacorasDB
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
SELECT id, fecha, resumen, modo, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
|
||||
#{where_clause}
|
||||
ORDER BY fecha DESC
|
||||
@@ -261,7 +272,7 @@ module BitacorasDB
|
||||
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
SELECT id, fecha, resumen, modo, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
|
||||
WHERE fecha = $1
|
||||
SQL
|
||||
@@ -280,7 +291,7 @@ module BitacorasDB
|
||||
# @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)
|
||||
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true, ambito_id: nil)
|
||||
tipos_validos = ['servidor', 'hipervisor', 'vm', 'pc', 'vmware', 'xenserver', 'servicio']
|
||||
|
||||
unless tipos_validos.include?(tipo.downcase)
|
||||
@@ -288,12 +299,12 @@ module BitacorasDB
|
||||
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
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo, ambito_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, nombre, ip, tipo, descripcion, activo, ambito_id, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo])
|
||||
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo, ambito_id])
|
||||
result.first
|
||||
end
|
||||
|
||||
@@ -303,30 +314,37 @@ module BitacorasDB
|
||||
# @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)
|
||||
def list_nodos(activo: nil, tipo: nil, ambito_id: nil, limit: 100)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if !activo.nil?
|
||||
conditions << "activo = $#{param_index}"
|
||||
conditions << "n.activo = $#{param_index}"
|
||||
params << activo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tipo
|
||||
conditions << "tipo = $#{param_index}"
|
||||
conditions << "n.tipo = $#{param_index}"
|
||||
params << tipo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if ambito_id
|
||||
conditions << "n.ambito_id = $#{param_index}"
|
||||
params << ambito_id
|
||||
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]}
|
||||
SELECT n.id, n.nombre, n.ip, n.tipo, n.descripcion, n.activo, n.ambito_id, a.nombre as ambito_nombre, n.created_at, n.updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:nodos]} n
|
||||
LEFT JOIN #{SCHEMA}.ambitos a ON n.ambito_id = a.id
|
||||
#{where_clause}
|
||||
ORDER BY nombre
|
||||
ORDER BY n.nombre
|
||||
LIMIT $#{param_index}
|
||||
SQL
|
||||
|
||||
@@ -351,6 +369,68 @@ module BitacorasDB
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# Actualiza un nodo existente
|
||||
#
|
||||
# @param nombre [String] Nombre del nodo a actualizar
|
||||
# @param ip [String, nil] Nueva dirección IP
|
||||
# @param tipo [String, nil] Nuevo tipo
|
||||
# @param descripcion [String, nil] Nueva descripción
|
||||
# @param activo [Boolean, nil] Nuevo estado activo
|
||||
# @param ambito_id [Integer, nil] Nuevo ámbito ID
|
||||
# @return [Hash, nil] Nodo actualizado o nil si no existe
|
||||
def update_nodo(nombre:, ip: nil, tipo: nil, descripcion: nil, activo: nil, ambito_id: nil)
|
||||
nodo = find_nodo_by_nombre(nombre)
|
||||
return nil unless nodo
|
||||
|
||||
updates = []
|
||||
params = [nombre]
|
||||
param_index = 2
|
||||
|
||||
if ip
|
||||
updates << "ip = $#{param_index}"
|
||||
params << ip
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tipo
|
||||
updates << "tipo = $#{param_index}"
|
||||
params << tipo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if descripcion
|
||||
updates << "descripcion = $#{param_index}"
|
||||
params << descripcion
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if !activo.nil?
|
||||
updates << "activo = $#{param_index}"
|
||||
params << activo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if ambito_id
|
||||
updates << "ambito_id = $#{param_index}"
|
||||
params << ambito_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
return nodo if updates.empty?
|
||||
|
||||
updates << "updated_at = CURRENT_TIMESTAMP"
|
||||
|
||||
query = <<~SQL
|
||||
UPDATE #{SCHEMA}.#{TABLES[:nodos]}
|
||||
SET #{updates.join(', ')}
|
||||
WHERE nombre = $1
|
||||
RETURNING id, nombre, ip, tipo, descripcion, activo, ambito_id, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA ENTRADAS (I-F-D-E)
|
||||
|
||||
# Crea una nueva entrada cronológica
|
||||
@@ -377,13 +457,26 @@ module BitacorasDB
|
||||
estado = fin_time ? '✅' : '⏳'
|
||||
end
|
||||
|
||||
# Inferir Modo
|
||||
# Inferir Modo (herencia de jornada)
|
||||
if modo.nil? || modo.strip.empty?
|
||||
modo_bitacora = 'P'
|
||||
|
||||
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'
|
||||
res = @connection.exec_params(
|
||||
"SELECT modo FROM #{SCHEMA}.#{TABLES[:bitacoras]} WHERE id = $1",
|
||||
[bitacora_id]
|
||||
)
|
||||
modo_bitacora = res.first['modo'] if res.ntuples > 0
|
||||
end
|
||||
|
||||
# Regla: jornada presencial (P) + hora fuera de 8:00-13:00 → remoto (R)
|
||||
inicio_horario = Time.new(2000, 1, 1, 8, 0, 0)
|
||||
fin_horario = Time.new(2000, 1, 1, 13, 0, 0)
|
||||
|
||||
if modo_bitacora == 'P' && inicio_time >= fin_horario
|
||||
modo = 'R' # Fuera de horario presencial
|
||||
else
|
||||
modo = 'P'
|
||||
modo = modo_bitacora
|
||||
end
|
||||
end
|
||||
|
||||
@@ -415,7 +508,7 @@ module BitacorasDB
|
||||
entrada = result.first
|
||||
|
||||
# Registrar evento para el sensor de mejora continua
|
||||
MejorasSensor.registrar_evento(
|
||||
::ADN::MejorasSensor.registrar_evento(
|
||||
subcomando: 'evento:crear',
|
||||
args: [inicio_time, descripcion, fin_time, estado, modo, es_ia, tema_id, bitacora_id, nodo_id],
|
||||
nodo: nodo_id ? nil : 'desconocido', # Simplified for this example
|
||||
@@ -450,7 +543,7 @@ module BitacorasDB
|
||||
i = 1
|
||||
|
||||
params.each do |k, v|
|
||||
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id].include?(k)
|
||||
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id, :ambito_id, :doc_path].include?(k)
|
||||
val = ([:inicio, :fin].include?(k)) ? parse_time(v) : v
|
||||
set_clauses << "#{k} = $#{i}"
|
||||
values << val
|
||||
@@ -472,7 +565,7 @@ module BitacorasDB
|
||||
entrada = result.first&.to_h
|
||||
|
||||
# Registrar evento para el sensor de mejora continua
|
||||
MejorasSensor.registrar_evento(
|
||||
::ADN::MejorasSensor.registrar_evento(
|
||||
subcomando: 'evento:actualizar',
|
||||
args: [id] + values, # Simplified for this example
|
||||
nodo: nil, # In a real implementation, we would extract the nodo_id from the original entrada
|
||||
@@ -517,7 +610,7 @@ module BitacorasDB
|
||||
# @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,
|
||||
def list_entradas(bitacora_id: nil, nodo_id: nil, ambito_id: nil, tema_id: nil, estado: nil,
|
||||
desde: nil, hasta: nil, buscar: nil, limit: 100, offset: 0)
|
||||
conditions = []
|
||||
params = []
|
||||
@@ -535,6 +628,12 @@ module BitacorasDB
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if ambito_id
|
||||
conditions << "e.ambito_id = $#{param_index}"
|
||||
params << ambito_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tema_id
|
||||
conditions << "e.tema_id = $#{param_index}"
|
||||
params << tema_id
|
||||
@@ -569,14 +668,16 @@ module BitacorasDB
|
||||
|
||||
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,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.ambito_id, e.created_at, e.updated_at,
|
||||
b.fecha as bitacora_fecha,
|
||||
n.nombre as nodo_nombre,
|
||||
t.titulo as tema_titulo
|
||||
t.titulo as tema_titulo,
|
||||
a.nombre as ambito_nombre
|
||||
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
|
||||
LEFT JOIN #{SCHEMA}.ambitos a ON e.ambito_id = a.id
|
||||
#{where_clause}
|
||||
ORDER BY COALESCE(b.fecha, CURRENT_DATE) DESC, e.inicio DESC
|
||||
LIMIT $#{param_index} OFFSET $#{param_index + 1}
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
# 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'
|
||||
require 'connection_pool'
|
||||
require_relative './mejoras_sensor'
|
||||
|
||||
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_pool = nil
|
||||
@logger = logger || default_logger
|
||||
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
|
||||
|
||||
# Obtiene una conexión del pool
|
||||
#
|
||||
# @return [PG::Connection] Conexión PostgreSQL del pool
|
||||
# @raise [PG::Error] Si falla la conexión
|
||||
def connection
|
||||
return @connection_pool if @connection_pool
|
||||
|
||||
begin
|
||||
@logger&.info("Creando pool de conexiones a PostgreSQL: #{@config['host']}:#{@config['port']}/#{@config['database']}")
|
||||
|
||||
@connection_pool = ConnectionPool.new(size: 5, timeout: 5) do
|
||||
PG.connect(
|
||||
host: @config['host'],
|
||||
port: @config['port'],
|
||||
dbname: @config['database'],
|
||||
user: @config['username'],
|
||||
password: @config['password'],
|
||||
connect_timeout: @config['timeout'] || 5000
|
||||
)
|
||||
end
|
||||
|
||||
# Configurar búsqueda de esquema en todas las conexiones del pool
|
||||
@connection_pool.with do |conn|
|
||||
if @config['schema_search_path']
|
||||
conn.exec("SET search_path TO #{@config['schema_search_path']}")
|
||||
end
|
||||
end
|
||||
|
||||
@logger&.info("Pool de conexiones a PostgreSQL establecido correctamente")
|
||||
@connection_pool
|
||||
rescue PG::Error => e
|
||||
@logger&.error("Error creando pool de conexiones a PostgreSQL: #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# Cierra todas las conexiones en el pool
|
||||
def disconnect
|
||||
if @connection_pool
|
||||
@connection_pool.shutdown
|
||||
@connection_pool = nil
|
||||
@logger&.info("Pool de conexiones a PostgreSQL cerrado")
|
||||
end
|
||||
end
|
||||
|
||||
# Ejecuta un bloque con una conexión del pool, cerrando automáticamente al final
|
||||
#
|
||||
# @yield [BitacoraDB] Instancia de BitacoraDB con conexión establecida
|
||||
# @return [Object] Resultado del bloque
|
||||
def with_connection(&block)
|
||||
# Obtiene una conexión del pool y la devuelve al final
|
||||
connection.with do |conn|
|
||||
# Temporarily replace @connection with the pooled connection for the block
|
||||
original_connection = @connection
|
||||
@connection = conn
|
||||
begin
|
||||
yield self
|
||||
ensure
|
||||
@connection = original_connection
|
||||
end
|
||||
end
|
||||
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 hay una conexión válida en el pool
|
||||
#
|
||||
# @return [Boolean] true si hay una conexión válida en el pool
|
||||
def connection_test?
|
||||
connection.with do |conn|
|
||||
begin
|
||||
conn.exec('SELECT 1')
|
||||
true
|
||||
rescue PG::Error
|
||||
false
|
||||
end
|
||||
end
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
# Obtiene información de salud de la base de datos
|
||||
#
|
||||
# @param incluir_sugerencias [Boolean] Si se deben incluir sugerencias de mejora
|
||||
# @return [Hash] Información de estado y estadísticas
|
||||
def health_check(incluir_sugerencias: false)
|
||||
stats = statistics
|
||||
{
|
||||
conexion_activa: connection_test?,
|
||||
version_postgresql: version_postgresql,
|
||||
estadisticas: stats
|
||||
}.tap do |resultado|
|
||||
if incluir_sugerencias && MejorasSensor.activo?
|
||||
resultado[:sugerencias_mejora] = MejorasSensor.generar_sugerencias
|
||||
end
|
||||
end
|
||||
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]
|
||||
)
|
||||
entrada = result.first
|
||||
|
||||
# Registrar evento para el sensor de mejora continua
|
||||
MejorasSensor.registrar_evento(
|
||||
subcomando: 'evento:crear',
|
||||
args: [inicio_time, descripcion, fin_time, estado, modo, es_ia, tema_id, bitacora_id, nodo_id],
|
||||
nodo: nodo_id ? nil : 'desconocido', # Simplificado para este ejemplo
|
||||
estado_salida: estado,
|
||||
duracion_ms: 0, # En una implementación real, mediríamos el tiempo
|
||||
es_ia: es_ia,
|
||||
contexto: {
|
||||
conexiones_usadas: 1, # Simplificado
|
||||
operacion: 'create_entrada'
|
||||
}
|
||||
) if @activo # Solo si el sensor está activo
|
||||
|
||||
# Ejecutar hooks después de la creación
|
||||
ejecutar_hooks_entrada(entrada, :crear) if entrada
|
||||
|
||||
entrada
|
||||
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 [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id].include?(k)
|
||||
val = ([:inicio, :fin].include?(k)) ? 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)
|
||||
entrada = result.first&.to_h
|
||||
|
||||
# Registrar evento para el sensor de mejora continua
|
||||
MejorasSensor.registrar_evento(
|
||||
subcomando: 'evento:actualizar',
|
||||
args: [id] + values, # Simplificado para este ejemplo
|
||||
nodo: nil, # En una implementación real, extraeríamos el nodo_id de la entrada original
|
||||
estado_salida: entrada&.dig(:estado),
|
||||
duracion_ms: 0, # En una implementación real, mediríamos el tiempo
|
||||
es_ia: false, # En una implementación real, lo determinaríamos desde el contexto
|
||||
contexto: {
|
||||
conexiones_usadas: 1, # Simplificado
|
||||
operacion: 'update_entrada',
|
||||
id_actualizado: id
|
||||
}
|
||||
) if @activo && entrada # Solo si el sensor está activo y hay entrada
|
||||
|
||||
# Ejecutar hooks después de la actualización
|
||||
ejecutar_hooks_entrada(entrada, :actualizar) if entrada
|
||||
|
||||
entrada
|
||||
end
|
||||
|
||||
# Elimina una entrada existente
|
||||
# @param id [Integer] ID de la entrada a eliminar
|
||||
# @return [Boolean] true si se eliminó, false si no existe
|
||||
def delete_entrada(id)
|
||||
query = <<~SQL
|
||||
DELETE FROM #{SCHEMA}.#{TABLES[:entradas]}
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [id])
|
||||
result.ntuples > 0
|
||||
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
|
||||
|
||||
# Ejecuta hooks personalizados después de operaciones de entrada
|
||||
# Esto implementa el concepto de DB Hooks para migrar triggers.yml a capa de datos
|
||||
#
|
||||
# @param entrada [Hash] La entrada que fue creada o actualizada
|
||||
# @param accion [Symbol] :crear o :actualizar
|
||||
def ejecutar_hooks_entrada(entrada, accion)
|
||||
# Hook para proyectos con etiqueta [P2604]
|
||||
if entrada[:descripcion]&.match?(/\[P2604\]/)
|
||||
proyecto_codigo = entrada[:descripcion].match(/\[P2604\.(\d+)\.(\d+)\.(\d+)\]/)&.to_s
|
||||
if proyecto_codigo
|
||||
# Normalizar formato: P2604.05.01 -> P2604
|
||||
proyecto_base = proyecto_codigo.split('.')[0..1].join('.')
|
||||
|
||||
# Actualizar el manifiesto del proyecto en la base de datos
|
||||
proyecto = find_proyecto_by_codigo(proyecto_base)
|
||||
if proyecto
|
||||
# Aquí podríamos actualizar algún campo del proyecto basado en la entrada
|
||||
# Por ahora, solo registramos que se debería actualizar
|
||||
@logger&.info("Hook ejecutado: Entrada #{accion} para proyecto #{proyecto_base} detectada")
|
||||
# En una implementación completa, aquí actualizaríamos el manifiesto del proyecto
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Otros hooks podrían ir aquí...
|
||||
# Por ejemplo, hooks para sincronización con nodos, etc.
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,227 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/mejoras_sensor.rb — Sensor de mejora continua para el ecosistema ADN
|
||||
# ======================================================================================
|
||||
# Este módulo implementa un sensor ligero que recopila métricas de uso del sistema
|
||||
# y genera sugerencias de mejora basadas en patrones detectados.
|
||||
#
|
||||
# Diseñado para seguir los principios ADN:
|
||||
# - DB-First: Todas las sugerencias se registran como entradas en la bitácora
|
||||
# - Menos es Más: Lógica simple, reglas transparentes, sobrecarga mínima
|
||||
# - Armonía Integral: Nada se aplica automáticamente; requiere intervención humana trazable
|
||||
#
|
||||
# Uso interno:
|
||||
# MejorasSensor.registrar_evento(...) # Llamado desde operaciones clave
|
||||
# MejorasSensor.generar_sugerencias() # Genera entradas ⏳ en bitácora
|
||||
|
||||
require 'digest/md5'
|
||||
require_relative 'bitacora_db'
|
||||
|
||||
module ADN
|
||||
module MejorasSensor
|
||||
# Configuración del sensor
|
||||
@activo = true # Desactivado por defecto para minimizar overhead
|
||||
@umbrales = {
|
||||
conexiones_por_minuto: 5, # Umbral para sugerir connection pooling
|
||||
comandos_repetidos: 5, # Umbral para sugerir automatización
|
||||
ventana_tiempo: 300 # Ventana de análisis en segundos (5 min)
|
||||
}
|
||||
|
||||
# Activar/desactivar el sensor
|
||||
# @param activo [Boolean] Estado de activación
|
||||
def self.activo=(activo)
|
||||
@activo = activo
|
||||
end
|
||||
|
||||
# @return [Boolean] Estado actual de activación
|
||||
def self.activo?
|
||||
@activo
|
||||
end
|
||||
|
||||
# Registrar un evento de uso para análisis posterior
|
||||
# @param subcomando [String] Subcomando ejecutado (ej: 'evento:crear')
|
||||
# @param args [Array] Argumentos del subcomando
|
||||
# @param nodo [String, nil] Nodo involucrado (si aplica)
|
||||
# @param estado_salida [String] Estado de salida (✅, ❌, ⏳)
|
||||
# @param duracion_ms [Integer] Duración de ejecución en milisegundos
|
||||
# @param es_ia [Boolean] Si fue ejecutado por IA
|
||||
# @param contexto [Hash] Datos adicionales para análisis
|
||||
def self.registrar_evento(subcomando:, args: [], nodo: nil, estado_salida: nil,
|
||||
duracion_ms: 0, es_ia: false, contexto: {})
|
||||
return unless @activo # No hacer nada si el sensor está desactivado
|
||||
|
||||
# Generar hash de argumentos para agrupat patrones similares
|
||||
args_str = args.map(&:to_s).join('|')
|
||||
args_hash = Digest::MD5.hexdigest(args_str)[0, 8] # Primeros 8 caracteres
|
||||
|
||||
# Preparar contexto para almacenamiento
|
||||
contexto_json = contexto.to_json
|
||||
|
||||
# Almacenar métrica en la base de datos
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
db.execute <<~SQL, [subcomando, args_hash, nodo, estado_salida, duracion_ms, es_ia, contexto_json]
|
||||
INSERT INTO bitacoras.mejoras_metricas (
|
||||
subcomando, args_hash, nodo, estado_salida, duracion_ms, es_ia, contexto
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
# Generar sugerencias de mejora basadas en las métricas recopiladas
|
||||
# @return [Array<Hash>] Lista de sugerencias generadas
|
||||
def self.generar_sugerencias
|
||||
sugerencias = []
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# 1. Detectar fricción de conexiones DB excesivas
|
||||
sugerencias_conexiones = _detectar_friccion_conexiones(db)
|
||||
sugerencias.concat(sugerencias_conexiones)
|
||||
|
||||
# 2. Detectar comandos repetitivos que podrían automatizarse
|
||||
sugerencias_automatizacion = _detectar_comandos_repetitivos(db)
|
||||
sugerencias.concat(sugerencias_automatizacion)
|
||||
|
||||
# 3. Detectar inconsistencias documento-DB (ej: entradas [P2604] sin actualizaciones)
|
||||
sugerencias_inconsistencia = _detectar_inconsistencias_documento_db(db)
|
||||
sugerencias.concat(sugerencias_inconsistencia)
|
||||
end
|
||||
|
||||
sugerencias
|
||||
end
|
||||
|
||||
# Aplicar una sugerencia específica (marcar como implementada)
|
||||
# @param sugerencia_id [Integer] ID de la entrada de bitácora a actualizar
|
||||
# @return [Boolean] True si se actualizó exitosamente
|
||||
def self.aplicar_sugerencia(sugerencia_id)
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
resultado = db.update_entrada(sugerencia_id, { estado: '✅' })
|
||||
resultado.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Detectar fricción de conexiones DB excesivas
|
||||
# @param db [BitacoraDB] Instancia de conexión a la base de datos
|
||||
# @return [Array<Hash>] Sugerencias relacionadas con conexiones DB
|
||||
def self._detectar_friccion_conexiones(db)
|
||||
sugerencias = []
|
||||
ventana = Time.now - @umbrales[:ventana_tiempo]
|
||||
|
||||
# Consultar promedio de conexiones usadas por minuto en la ventana de tiempo
|
||||
resultado = db.execute(<<~SQL, [ventana])
|
||||
SELECT
|
||||
AVG((contexto->>'conexiones_usadas')::int) as avg_conexiones,
|
||||
COUNT(*) as total_muestras,
|
||||
SUBSTRING(MAX(momento)::text, 1, 19) as ultima_medicion
|
||||
FROM bitacoras.mejoras_metricas
|
||||
WHERE momento > $1
|
||||
AND contexto ? 'conexiones_usadas'
|
||||
SQL
|
||||
|
||||
fila = resultado.first
|
||||
avg_conexiones = (fila['avg_conexiones'] || 0).to_f
|
||||
total_muestras = fila['total_muestras'].to_i
|
||||
|
||||
if total_muestras >= 3 && avg_conexiones > @umbrales[:conexiones_por_minuto]
|
||||
sugerencias << {
|
||||
tipo: :conexiones_excessivas,
|
||||
descripcion: "Detectadas #{avg_conexiones.round(1)} conexiones DB promedio por minuto en las últimas #{@umbrales[:ventana_tiempo]/60} minutos. Umbral: #{@umbrales[:conexiones_por_minuto]}",
|
||||
accion_sugerida: "Implementar connection pooling en BitacoraDB (similar a lo hecho en #1202)",
|
||||
prioridad: :alta,
|
||||
datos: {
|
||||
avg_conexiones: avg_conexiones,
|
||||
total_muestras: total_muestras,
|
||||
umbral: @umbrales[:conexiones_por_minuto],
|
||||
ventana_minutos: @umbrales[:ventana_tiempo]/60
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
sugerencias
|
||||
end
|
||||
|
||||
# Detectar comandos repetitivos que podrían beneficiarse de automatización
|
||||
# @param db [BitacoraDB] Instancia de conexión a la base de datos
|
||||
# @return [Array<Hash>] Sugerencias relacionadas con automatización
|
||||
def self._detectar_comandos_repetitivos(db)
|
||||
sugerencias = []
|
||||
ventana = Time.now - @umbrales[:ventana_tiempo]
|
||||
|
||||
# Encontrar subcomandos con argumentos similares que se repiten frecuentemente
|
||||
resultado = db.execute(<<~SQL, [ventana, @umbrales[:comandos_repetitivos]])
|
||||
SELECT
|
||||
subcomando,
|
||||
args_hash,
|
||||
COUNT(*) as frecuencia,
|
||||
MAX(momento)::timestamp as ultima_ejecucion,
|
||||
BOOL_AND(es_ia) as todas_ia
|
||||
FROM bitacoras.mejoras_metricas
|
||||
WHERE momento > $1
|
||||
GROUP BY subcomando, args_hash
|
||||
HAVING COUNT(*) >= $2
|
||||
ORDER BY frecuencia DESC
|
||||
LIMIT 10
|
||||
SQL
|
||||
|
||||
resultado.each do |fila|
|
||||
subcomando = fila['subcomando']
|
||||
frecuencia = fila['frecuencia'].to_i
|
||||
es_ia = fila['todas_ia'] == 't' || fila['todas_ia'] == true
|
||||
|
||||
sugerencias << {
|
||||
tipo: :comando_repetitivo,
|
||||
descripcion: "Subcomando '#{subcomando}' ejecutado #{frecuencia} veces en las últimas #{@umbrales[:ventana_tiempo]/60} minutos con argumentos similares",
|
||||
accion_sugerida: "Considerar crear hook automático o plantilla para este patrón de uso",
|
||||
prioridad: es_ia ? :alta : :media, # Mayor prioridad si es usado por IA
|
||||
datos: {
|
||||
subcomando: subcomando,
|
||||
frecuencia: frecuencia,
|
||||
ventana_minutos: @umbrales[:ventana_tiempo]/60,
|
||||
es_ia: es_ia
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
sugerencias
|
||||
end
|
||||
|
||||
# Detectar inconsistencias documento-DB (ej: entradas [P2604] sin actualizaciones en proyectos)
|
||||
# @param db [BitacoraDB] Instancia de conexión a la base de datos
|
||||
# @return [Array<Hash>] Sugerencias relacionadas con inconsistencias
|
||||
def self._detectar_inconsistencias_documento_db(db)
|
||||
sugerencias = []
|
||||
ventana = Time.now - @umbrales[:ventana_tiempo]
|
||||
|
||||
# Buscar entradas con etiquetas de proyectos que podrían necesitar actualización de manifiesto
|
||||
resultado = db.execute(<<~SQL, [ventana])
|
||||
SELECT
|
||||
COUNT(*) as count_entradas,
|
||||
SUBSTRING(MAX(e.created_at)::text, 1, 19) as ultima_entrada
|
||||
FROM bitacoras.entradas e
|
||||
JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
|
||||
WHERE b.fecha > $1::date
|
||||
AND e.descripcion ~* 'P2604\.'
|
||||
SQL
|
||||
|
||||
fila = resultado.first
|
||||
count_entradas = fila['count_entradas'].to_i
|
||||
|
||||
if count_entradas > 0
|
||||
sugerencias << {
|
||||
tipo: :inconsistencia_documento_db,
|
||||
descripcion: "Detectadas #{count_entradas} entradas con etiquetas [P2604.*] en las últimas #{@umbrales[:ventana_tiempo]/60} minutos que podrían requerir actualización de manifiesto de proyecto",
|
||||
accion_sugerida: "Verificar si se necesita actualizar documentos en docs/proy/P2604/... o mejorar DB Hooks",
|
||||
prioridad: :media,
|
||||
datos: {
|
||||
count_entradas: count_entradas,
|
||||
ventana_minutos: @umbrales[:ventana_tiempo]/60
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
sugerencias
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user