[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:
@@ -8,5 +8,7 @@
|
||||
"dominio-utnlarioja.intranet:monlaricardo": "eyJpdiI6InFRQ3VNYWlVSHlKMURNdzciLCJ0YWciOiJ1a3hESUZlaEt6enV3UXZxWUFrWXhRPT0iLCJkYXRhIjoiNVNaekdxVyt6dE5WQmhPaXF3PT0ifQ==",
|
||||
"ns8:rmonla:sudo": "eyJpdiI6IkRqbTBhVlZSYUpVdno3TFkiLCJ0YWciOiJJSnE2ei8zZHIrV2hFQks1VjNpVGN3PT0iLCJkYXRhIjoiT3JxTjRhTmlQK2R3TlJZZk9BPT0ifQ==",
|
||||
"srv-dasu:root": "eyJpdiI6Ik5LY09JR1FvaFVPRXd3bjUiLCJ0YWciOiJwb0NLZ3A4N3VOQUZMQWhMS1hUVWZnPT0iLCJkYXRhIjoicEgvWTFmd0RhSFQ0YWRHRUxyZ1lBQT09In0=",
|
||||
"srv-ns8:rmonla:sudo": "eyJpdiI6InJDOUp2STdYR1crb2NkMTgiLCJ0YWciOiJHSUZVbXM5VnVmZ3d1NXQ3SFVwOUdRPT0iLCJkYXRhIjoib1ZVTzdaLzk1WGdwZDhKREM3aWpnV3c9In0="
|
||||
"srv-ns8:rmonla:sudo": "eyJpdiI6InJDOUp2STdYR1crb2NkMTgiLCJ0YWciOiJHSUZVbXM5VnVmZ3d1NXQ3SFVwOUdRPT0iLCJkYXRhIjoib1ZVTzdaLzk1WGdwZDhKREM3aWpnV3c9In0=",
|
||||
"dasu-sql2:Administrator": "eyJpdiI6IlJ0VWJzblVGM2NhWEtvNlgiLCJ0YWciOiJtM0llb2luUmRUZng0NnBsL1JtKzl3PT0iLCJkYXRhIjoiSHVhaklOTFlsSm1PbDJkTjNoYlF0UT09In0=",
|
||||
"srvv-nginx-rm:root": "eyJpdiI6Ijc0STVkTk92Tk5LZEZZWWciLCJ0YWciOiJlOTAwcGpTa21PZFVhN2FJTzIxU1VBPT0iLCJkYXRhIjoiYUJRR3hRakFub1F1OCtyTVdWcW8ifQ=="
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1773874654
|
||||
1774708556
|
||||
@@ -58,7 +58,9 @@ eval $(ruby candados.rb load srv-dasu:rmonla)
|
||||
# USR y PASS están disponibles en el shell
|
||||
```
|
||||
|
||||
Internamente escribe los exports a un archivo temporal (`/tmp/.candados_env_<hex>`) con permisos `600`, y a stdout solo emite el comando `source` + autoborrado.
|
||||
Internamente escribe los exports a un archivo temporal (`tmp/.candados_env_<hex>`) con permisos `600`, y a stdout solo emite el comando `source` + autoborrado.
|
||||
|
||||
**Nota:** Los temporales se guardan en `tmp/` del repositorio (no en `/tmp/`) para cumplir con el principio 11 de `05_ia.md` (Workspace Bounds).
|
||||
|
||||
## 🔑 Resolución Automática de Pares
|
||||
|
||||
|
||||
@@ -38,11 +38,13 @@ require 'fileutils'
|
||||
|
||||
# ─── Rutas ───────────────────────────────────────────────────────────
|
||||
TOOL_DIR = File.expand_path(__dir__)
|
||||
PROJECT_ROOT = File.expand_path('../../..', TOOL_DIR) # Desde adn/tools/candados -> adn/tools -> adn -> root
|
||||
MASTER_KEY = File.join(TOOL_DIR, '.master.key')
|
||||
OLD_KEY = File.expand_path('../../.master.key', TOOL_DIR)
|
||||
BOVEDA = File.join(TOOL_DIR, '.boveda.json')
|
||||
SESION = File.join(TOOL_DIR, '.session')
|
||||
ACCESS_LOG = File.join(TOOL_DIR, 'access.log')
|
||||
TMP_DIR = File.join(PROJECT_ROOT, 'tmp') # Directorio para temporales (principio 11 - 05_ia.md)
|
||||
|
||||
# ─── Transición de Seguridad ─────────────────────────────────────────
|
||||
if File.exist?(OLD_KEY) && !File.exist?(MASTER_KEY)
|
||||
@@ -175,20 +177,41 @@ end
|
||||
|
||||
# ─── Comandos ────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_abrir
|
||||
puts "#{C::CYAN}🔐 candados: Autorización SSH#{C::RESET}"
|
||||
puts "#{C::DIM}Limpiando identidades previas...#{C::RESET}"
|
||||
def cmd_abrir(cripto, boveda)
|
||||
verificar_sesion!
|
||||
token = boveda.get('rsa')
|
||||
abort "#{C::RED}✗ Clave 'rsa' no encontrada en la bóveda. Guardala con: ruby candados.rb set rsa#{C::RESET}" if token.nil?
|
||||
|
||||
passphrase = cripto.decrypt(token)
|
||||
registrar_acceso('ABRIR', 'SSH key load via ssh-add')
|
||||
|
||||
$stderr.puts "#{C::CYAN}🔐 candados: Cargando clave SSH...#{C::RESET}"
|
||||
system('ssh-add -D > /dev/null 2>&1')
|
||||
|
||||
puts "#{C::YELLOW}🔑 Ingresá tu passphrase:#{C::RESET}"
|
||||
system('ssh-add')
|
||||
# Crear script temporal para SSH_ASKPASS (no interactivo)
|
||||
require 'securerandom'
|
||||
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
|
||||
askpass_path = File.join(TMP_DIR, ".ssh_askpass_#{SecureRandom.hex(4)}")
|
||||
safe_pass = passphrase.gsub("'", "'\\''")
|
||||
File.write(askpass_path, "#!/bin/sh\necho '#{safe_pass}'\n")
|
||||
File.chmod(0700, askpass_path)
|
||||
|
||||
system('ssh-add -l > /dev/null 2>&1')
|
||||
unless $?.success?
|
||||
abort "#{C::RED}✗ Sin identidades SSH cargadas. Abortando.#{C::RESET}"
|
||||
# Cargar clave via SSH_ASKPASS
|
||||
ENV['SSH_ASKPASS'] = askpass_path
|
||||
ENV['SSH_ASKPASS_REQUIRE'] = 'force'
|
||||
ENV['DISPLAY'] = ENV['DISPLAY'] || ':0'
|
||||
system('ssh-add < /dev/null')
|
||||
|
||||
# Limpiar
|
||||
FileUtils.rm_f(askpass_path)
|
||||
ENV.delete('SSH_ASKPASS')
|
||||
ENV.delete('SSH_ASKPASS_REQUIRE')
|
||||
|
||||
if system('ssh-add -l > /dev/null 2>&1')
|
||||
$stderr.puts "#{C::GREEN}🔓 SSH autorizado. Identidades cargadas en el agente.#{C::RESET}"
|
||||
else
|
||||
abort "#{C::RED}✗ Sin identidades SSH cargadas. Verificá la passphrase en 'rsa'.#{C::RESET}"
|
||||
end
|
||||
|
||||
puts "#{C::GREEN}🔓 SSH autorizado. Las identidades permanecen activas mientras dure la sesión del agente.#{C::RESET}"
|
||||
end
|
||||
|
||||
def cmd_encrypt(cripto, texto)
|
||||
@@ -326,8 +349,10 @@ def cmd_load(cripto, boveda, clave)
|
||||
# Seguridad: NUNCA imprimir secretos a stdout/stderr.
|
||||
# Escribir exports en un archivo temporal con permisos estrictos (600).
|
||||
# A stdout solo emitimos el comando `source` + autoborrado.
|
||||
# NOTA: Usamos tmp/ del proyecto (no /tmp/) para cumplir principio 11 de 05_ia.md
|
||||
require 'securerandom'
|
||||
tmpfile_path = "/tmp/.candados_env_#{SecureRandom.hex(8)}"
|
||||
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
|
||||
tmpfile_path = File.join(TMP_DIR, ".candados_env_#{SecureRandom.hex(8)}")
|
||||
|
||||
safe_user = user_value.gsub("'", "'\\''")
|
||||
safe_pass = pass_value.gsub("'", "'\\''")
|
||||
@@ -463,6 +488,10 @@ if __FILE__ == $0
|
||||
autorizar!
|
||||
when 'cerrar'
|
||||
cerrar_sesion!
|
||||
when 'abrir'
|
||||
cripto = Cripto.new(cargar_master_key)
|
||||
boveda = Boveda.new(BOVEDA)
|
||||
cmd_abrir(cripto, boveda)
|
||||
when 'sudo'
|
||||
cripto = Cripto.new(cargar_master_key)
|
||||
boveda = Boveda.new(BOVEDA)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../core/colores'
|
||||
require_relative '../core/help_formatter'
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
module ADN
|
||||
class SubcomandoAmbitos
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || ['help', '-h', '--help'].include?(@args[0])
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
subcomando = @args[0]
|
||||
sub_args = @args[1..-1] || []
|
||||
|
||||
case subcomando
|
||||
when 'listar'
|
||||
comando_listar(sub_args)
|
||||
when 'crear'
|
||||
comando_crear(sub_args)
|
||||
when 'info'
|
||||
comando_info(sub_args)
|
||||
when 'nodos'
|
||||
comando_nodos(sub_args)
|
||||
else
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
puts "\n"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
|
||||
exit 130
|
||||
rescue => e
|
||||
@logger.error("Error en operación de ámbitos: #{e.message}")
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def comando_listar(args)
|
||||
opts = { activo: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on("--activo", "Mostrar solo ámbitos activos") { opts[:activo] = true }
|
||||
opt.on("--inactivo", "Mostrar solo ámbitos inactivos") { opts[:activo] = false }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts HelpFormatter.generar_help(
|
||||
titulo: "Listar Ámbitos",
|
||||
descripcion: "Muestra la lista de ámbitos (agrupadores de nodos).",
|
||||
uso: HelpFormatter.format_usage("adn ambitos listar", "[OPCIONES]"),
|
||||
opciones: [
|
||||
{ names: ["activo"], desc: "Mostrar solo ámbitos activos" },
|
||||
{ names: ["inactivo"], desc: "Mostrar solo ámbitos inactivos" },
|
||||
{ names: ["h", "help"], desc: "Mostrar esta ayuda" }
|
||||
],
|
||||
ejemplos: [{ cmd: "./adn/tools/run ambitos listar", desc: "Listar todos los ámbitos" }]
|
||||
)
|
||||
exit 0
|
||||
end
|
||||
end
|
||||
|
||||
parsear_argumentos!(parser, args)
|
||||
|
||||
puts "#{Color::CYAN}📋 Listando ámbitos...#{Color::RESET}\n\n"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambitos = db.list_ambitos(activo: opts[:activo])
|
||||
|
||||
if ambitos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron ámbitos.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"Nodos".ljust(7)} #{"Activo".ljust(6)} #{"Padre".ljust(20)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 20} #{"─" * 7} #{"─" * 6} #{"─" * 20}"
|
||||
|
||||
ambitos.each do |a|
|
||||
id = a['id'].to_s.ljust(5)
|
||||
nombre = a['nombre'].to_s.ljust(20)
|
||||
count = a['count_nodos'].to_s.ljust(7)
|
||||
activo = (a['activo'] ? '✅' : '❌').ljust(6)
|
||||
padre = (a['parent_nombre'] || '-').ljust(20)
|
||||
|
||||
puts "#{id} #{nombre} #{count} #{activo} #{padre}"
|
||||
end
|
||||
puts "\nTotal: #{ambitos.length} ámbitos."
|
||||
end
|
||||
end
|
||||
|
||||
def comando_crear(args)
|
||||
opts = { nombre: nil, descripcion: nil, activo: true }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on("--nombre NOMBRE", "Nombre del ámbito") { |n| opts[:nombre] = n }
|
||||
opt.on("--descripcion DESC", "Descripción del ámbito") { |d| opts[:descripcion] = d }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts HelpFormatter.generar_help(
|
||||
titulo: "Crear Ámbito",
|
||||
descripcion: "Genera un nuevo ámbito para agrupar nodos de infraestructura.",
|
||||
uso: HelpFormatter.format_usage("adn ambitos crear", "--nombre NOMBRE [OPCIONES]"),
|
||||
opciones: [
|
||||
{ names: ["nombre NOMBRE"], desc: "Nombre único del ámbito (obligatorio)" },
|
||||
{ names: ["descripcion DESC"], desc: "Descripción del propósito del ámbito" },
|
||||
{ names: ["h", "help"], desc: "Mostrar esta ayuda" }
|
||||
],
|
||||
ejemplos: [{ cmd: "./adn/tools/run ambitos crear --nombre dtic-Pruebas", desc: "Crear un ámbito de pruebas" }]
|
||||
)
|
||||
exit 0
|
||||
end
|
||||
end
|
||||
|
||||
parsear_argumentos!(parser, args)
|
||||
|
||||
unless opts[:nombre]
|
||||
puts "#{Color::RED}✗ Error: El parámetro --nombre es obligatorio.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📦 Creando ámbito '#{opts[:nombre]}'...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
ambito = db.create_ambito(nombre: opts[:nombre], descripcion: opts[:descripcion])
|
||||
puts "#{Color::GREEN}✅ Ámbito creado exitosamente. ID: #{ambito['id']}#{Color::RESET}"
|
||||
rescue PG::UniqueViolation
|
||||
puts "#{Color::RED}✗ Error: Ya existe un ámbito con el nombre '#{opts[:nombre]}'.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def comando_info(args)
|
||||
opts = {}
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts HelpFormatter.generar_help(
|
||||
titulo: "Info de Ámbito",
|
||||
descripcion: "Muestra información y estadísticas detalladas de un ámbito.",
|
||||
uso: HelpFormatter.format_usage("adn ambitos info", "<nombre_o_id>"),
|
||||
opciones: [],
|
||||
ejemplos: [{ cmd: "./adn/tools/run ambitos info dtic-DASUTEN", desc: "Ver detalles de dtic-DASUTEN" }]
|
||||
)
|
||||
exit 0
|
||||
end
|
||||
end
|
||||
|
||||
parsear_argumentos!(parser, args)
|
||||
objetivo = args.first
|
||||
|
||||
if objetivo.nil? || objetivo.empty?
|
||||
puts "#{Color::RED}✗ Error: Debes especificar el nombre o ID del ámbito.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambito = db.get_ambito(objetivo)
|
||||
if ambito.nil?
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{objetivo}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}ℹ Info del Ámbito: #{ambito['nombre']}#{Color::RESET}\n\n"
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{ambito['id']}"
|
||||
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{ambito['nombre']}"
|
||||
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{ambito['descripcion'] || '(sin descripción)'}"
|
||||
puts " #{Color::BOLD}Padre:#{Color::RESET} #{ambito['parent_nombre'] || '(raíz)'}"
|
||||
puts " #{Color::BOLD}Activo:#{Color::RESET} #{ambito['activo'] ? 'Sí' : 'No'}"
|
||||
puts " #{Color::BOLD}Nodos:#{Color::RESET} #{ambito['count_nodos']}"
|
||||
puts ""
|
||||
end
|
||||
end
|
||||
|
||||
def comando_nodos(args)
|
||||
opts = {}
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts HelpFormatter.generar_help(
|
||||
titulo: "Nodos de Ámbito",
|
||||
descripcion: "Lista todos los nodos pertenecientes a un ámbito.",
|
||||
uso: HelpFormatter.format_usage("adn ambitos nodos", "<nombre_o_id>"),
|
||||
opciones: [],
|
||||
ejemplos: [{ cmd: "./adn/tools/run ambitos nodos dtic-DASUTEN", desc: "Ver componentes agrupados bajo dtic-DASUTEN" }]
|
||||
)
|
||||
exit 0
|
||||
end
|
||||
end
|
||||
|
||||
parsear_argumentos!(parser, args)
|
||||
objetivo = args.first
|
||||
|
||||
if objetivo.nil? || objetivo.empty?
|
||||
puts "#{Color::RED}✗ Error: Debes especificar el nombre o ID del ámbito.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambito = db.get_ambito(objetivo)
|
||||
if ambito.nil?
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{objetivo}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🖥️ Nodos del ámbito '#{ambito['nombre']}'...#{Color::RESET}\n\n"
|
||||
nodos = db.get_nodos_by_ambito(ambito['id'])
|
||||
|
||||
if nodos.empty?
|
||||
puts "#{Color::YELLOW}⚠ Este ámbito no tiene nodos asignados.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
nodos.each do |n|
|
||||
tipo = n['tipo'].to_s.ljust(10)
|
||||
ip = (n['ip'] || '-').ljust(15)
|
||||
estado = n['activo'] ? "#{Color::GREEN}ON#{Color::RESET}" : "#{Color::RED}OFF#{Color::RESET}"
|
||||
puts " #{Color::DIM}▸#{Color::RESET} #{Color::BOLD}#{n['nombre'].to_s.ljust(20)}#{Color::RESET} [#{tipo}] IP: #{ip} (#{estado})"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts HelpFormatter.generar_help(
|
||||
titulo: "Gestión de Ámbitos (Modelo DB-First)",
|
||||
descripcion: "Los ámbitos agrupan lógicamente a los nodos correspondientes a áreas organizativas.",
|
||||
uso: HelpFormatter.format_usage("adn ambitos", "<subcomando> [OPCIONES]"),
|
||||
opciones: [
|
||||
{ names: ["h", "help"], desc: "Mostrar esta ayuda" }
|
||||
],
|
||||
ejemplos: [
|
||||
{ cmd: "./adn/tools/run ambitos listar", desc: "Listar ámbitos y cantidad de nodos" },
|
||||
{ cmd: "./adn/tools/run ambitos info dtic-DIIAA", desc: "Ver detalle del ámbito DIIAA" },
|
||||
{ cmd: "./adn/tools/run ambitos nodos dtic-DASUTEN", desc: "Listar hipervisores e infraestructura de DASUTEN" }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def parsear_argumentos!(parser, args)
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts "\nEjecuta con --help para ver las opciones disponibles."
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+14
-130
@@ -42,10 +42,12 @@ module ADN
|
||||
require_relative 'db/bitacora'
|
||||
require_relative 'db/evento'
|
||||
require_relative 'db/nodo'
|
||||
require_relative 'db/ambito'
|
||||
require_relative 'db/hito'
|
||||
require_relative 'db/misc'
|
||||
include SubcomandoDBBitacora
|
||||
include SubcomandoDBEvento
|
||||
include SubcomandoDBAmbito
|
||||
include SubcomandoDBNodo
|
||||
include SubcomandoDBHito
|
||||
include SubcomandoDBMisc
|
||||
@@ -88,6 +90,8 @@ module ADN
|
||||
comando_nodo_crear(sub_args)
|
||||
when 'nodo:listar'
|
||||
comando_nodo_listar(sub_args)
|
||||
when 'nodo:actualizar'
|
||||
comando_nodo_actualizar(sub_args)
|
||||
when 'hito:listar'
|
||||
comando_hito_listar(sub_args)
|
||||
when 'hito:actualizar'
|
||||
@@ -98,10 +102,20 @@ module ADN
|
||||
comando_hito_ordenar(sub_args)
|
||||
when 'estadisticas'
|
||||
comando_estadisticas
|
||||
when 'ambito:listar'
|
||||
comando_ambito_listar(sub_args)
|
||||
when 'ambito:crear'
|
||||
comando_ambito_crear(sub_args)
|
||||
when 'ambito:info'
|
||||
comando_ambito_info(sub_args)
|
||||
when 'ambito:nodos'
|
||||
comando_ambito_nodos(sub_args)
|
||||
when 'backup'
|
||||
comando_backup(sub_args)
|
||||
when 'archivar:md'
|
||||
comando_archivar_md
|
||||
when 'migrar:sql'
|
||||
comando_migrar_sql(sub_args)
|
||||
|
||||
else
|
||||
@logger.error("Subcomando desconocido: #{subcomando}")
|
||||
@@ -181,134 +195,4 @@ end
|
||||
end
|
||||
end
|
||||
|
||||
module ADN
|
||||
class SubcomandoDB
|
||||
require_relative 'db/bitacora'
|
||||
require_relative 'db/evento'
|
||||
require_relative 'db/nodo'
|
||||
require_relative 'db/hito'
|
||||
require_relative 'db/misc'
|
||||
include SubcomandoDBBitacora
|
||||
include SubcomandoDBEvento
|
||||
include SubcomandoDBNodo
|
||||
include SubcomandoDBHito
|
||||
include SubcomandoDBMisc
|
||||
# Inicializa el subcomando con argumentos y logger
|
||||
#
|
||||
# @param args [Array<String>] Argumentos de línea de comandos
|
||||
# @param logger [ADNLogger] Instancia de logger para registro
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@config = cargar_configuracion
|
||||
end
|
||||
|
||||
# Ejecuta el subcomando según los argumentos proporcionados
|
||||
def ejecutar
|
||||
if @args.empty? || @args[0] == 'help' || @args[0] == '-h' || @args[0] == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
subcomando = @args[0]
|
||||
sub_args = @args[1..-1] || []
|
||||
|
||||
case subcomando
|
||||
when 'salud:bd'
|
||||
comando_salud_bd
|
||||
when 'bitacora:crear'
|
||||
comando_bitacora_crear(sub_args)
|
||||
when 'bitacora:listar'
|
||||
comando_bitacora_listar(sub_args)
|
||||
when 'evento:crear'
|
||||
comando_evento_crear(sub_args)
|
||||
when 'evento:listar'
|
||||
comando_evento_listar(sub_args)
|
||||
when 'evento:actualizar'
|
||||
comando_evento_actualizar(sub_args)
|
||||
when 'evento:eliminar'
|
||||
comando_evento_eliminar(sub_args)
|
||||
when 'nodo:crear'
|
||||
comando_nodo_crear(sub_args)
|
||||
when 'nodo:listar'
|
||||
comando_nodo_listar(sub_args)
|
||||
when 'hito:listar'
|
||||
comando_hito_listar(sub_args)
|
||||
when 'hito:actualizar'
|
||||
comando_hito_actualizar(sub_args)
|
||||
when 'hito:renombrar'
|
||||
comando_hito_renombrar(sub_args)
|
||||
when 'hito:ordenar'
|
||||
comando_hito_ordenar(sub_args)
|
||||
when 'estadisticas'
|
||||
comando_estadisticas
|
||||
when 'backup'
|
||||
comando_backup(sub_args)
|
||||
when 'archivar:md'
|
||||
comando_archivar_md
|
||||
|
||||
else
|
||||
@logger.error("Subcomando desconocido: #{subcomando}")
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
puts ""
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
|
||||
@logger.info("Operación de base de datos interrumpida por usuario")
|
||||
exit 130
|
||||
rescue => e
|
||||
@logger.error("Error en operación de base de datos: #{e.message}", { backtrace: e.backtrace })
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
|
||||
exit 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ─── COMANDOS PRINCIPALES ──────────────────────────────────────────────
|
||||
|
||||
# ─── MÉTODOS AUXILIARES ──────────────────────────────────────────────
|
||||
|
||||
# Carga la configuración desde config.yml
|
||||
def cargar_configuracion
|
||||
config_path = File.join(__dir__, 'config.yml')
|
||||
return {} unless File.exist?(config_path)
|
||||
|
||||
begin
|
||||
YAML.load_file(config_path)
|
||||
rescue => e
|
||||
@logger.error("Error cargando configuración: #{e.message}")
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# Muestra la ayuda general del subcomando
|
||||
def mostrar_ayuda
|
||||
puts "#{Color::BOLD}Uso: ./adn/tools/run db [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
|
||||
puts ""
|
||||
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::CYAN}salud:bd#{Color::RESET} Verificar conexión y estado de la BD"
|
||||
puts " #{Color::CYAN}backup [OPCIONES]#{Color::RESET} Crear backup de la base de datos"
|
||||
puts " #{Color::CYAN}bitacora:crear [FECHA]#{Color::RESET} Crear nueva bitácora diaria"
|
||||
puts " #{Color::CYAN}bitacora:listar [OPCIONES]#{Color::RESET} Listar bitácoras existentes"
|
||||
puts " #{Color::CYAN}evento:crear [OPCIONES]#{Color::RESET} Crear nueva entrada cronológica"
|
||||
puts " #{Color::CYAN}evento:listar [OPCIONES]#{Color::RESET} Listar entradas con filtros"
|
||||
puts " #{Color::CYAN}nodo:crear [OPCIONES]#{Color::RESET} Crear nuevo nodo (servidor/VM/PC)"
|
||||
puts " #{Color::CYAN}nodo:listar [OPCIONES]#{Color::RESET} Listar nodos del sistema"
|
||||
puts " #{Color::CYAN}estadisticas#{Color::RESET} Mostrar estadísticas del sistema"
|
||||
puts " #{Color::CYAN}archivar:md#{Color::RESET} Mueve los archivos .md migrados al archivo histórico"
|
||||
puts ""
|
||||
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
|
||||
puts " ./adn/tools/run db salud:bd"
|
||||
puts " ./adn/tools/run db bitacora:crear --resumen \"Inicio de proyecto\""
|
||||
puts " ./adn/tools/run db evento:crear --inicio 09:00 --descripcion \"Reunión\" --nodo srv-ns8"
|
||||
puts " ./adn/tools/run db nodo:listar --tipo servidor"
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Nota:#{Color::RESET} Requiere PostgreSQL en ejecución (puerto 5433)"
|
||||
puts " Iniciar contenedor: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../../core/colores'
|
||||
require_relative '../../core/help_formatter'
|
||||
|
||||
module ADN
|
||||
module SubcomandoDBAmbito
|
||||
# Comando: ambito:listar [OPCIONES] - Listar ámbitos
|
||||
def comando_ambito_listar(args)
|
||||
opts = { activo: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db ambito:listar [OPCIONES]"
|
||||
opt.on("--activo", "Mostrar solo ámbitos activos") { opts[:activo] = true }
|
||||
opt.on("--inactivo", "Mostrar solo ámbitos inactivos") { opts[:activo] = false }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📋 Listando ámbitos...#{Color::RESET}\n\n"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambitos = db.list_ambitos(activo: opts[:activo])
|
||||
|
||||
if ambitos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron ámbitos.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"Nodos".ljust(7)} #{"Activo".ljust(6)} #{"Padre".ljust(20)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 20} #{"─" * 7} #{"─" * 6} #{"─" * 20}"
|
||||
|
||||
ambitos.each do |a|
|
||||
id = a['id'].to_s.ljust(5)
|
||||
nombre = a['nombre'].to_s.ljust(20)
|
||||
count = a['count_nodos'].to_s.ljust(7)
|
||||
activo = (a['activo'] ? '✅' : '❌').ljust(6)
|
||||
padre = (a['parent_nombre'] || '-').ljust(20)
|
||||
|
||||
puts "#{id} #{nombre} #{count} #{activo} #{padre}"
|
||||
end
|
||||
puts "\nTotal: #{ambitos.length} ámbitos."
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: ambito:crear [OPCIONES] - Crear nuevo ámbito
|
||||
def comando_ambito_crear(args)
|
||||
opts = { nombre: nil, descripcion: nil, parent_id: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db ambito:crear --nombre NOMBRE [OPCIONES]"
|
||||
opt.on("--nombre NOMBRE", "Nombre único del ámbito (obligatorio)") { |n| opts[:nombre] = n }
|
||||
opt.on("--descripcion DESC", "Descripción del ámbito") { |d| opts[:descripcion] = d }
|
||||
opt.on("--parent-id ID", Integer, "ID del ámbito padre") { |p| opts[:parent_id] = p }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
unless opts[:nombre]
|
||||
puts "#{Color::RED}✗ Error: El parámetro --nombre es obligatorio.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📦 Creando ámbito '#{opts[:nombre]}'...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
ambito = db.create_ambito(
|
||||
nombre: opts[:nombre],
|
||||
descripcion: opts[:descripcion],
|
||||
parent_id: opts[:parent_id]
|
||||
)
|
||||
puts "#{Color::GREEN}✅ Ámbito creado exitosamente. ID: #{ambito['id']}#{Color::RESET}"
|
||||
rescue PG::UniqueViolation
|
||||
puts "#{Color::RED}✗ Error: Ya existe un ámbito con el nombre '#{opts[:nombre]}'.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: ambito:info [NOMBRE_O_ID] - Info detallada
|
||||
def comando_ambito_info(args)
|
||||
objetivo = args.first
|
||||
if objetivo.nil? || objetivo.start_with?('-')
|
||||
puts "#{Color::YELLOW}Uso: adn db ambito:info <nombre_o_id>#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambito = db.get_ambito(objetivo)
|
||||
if ambito.nil?
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{objetivo}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}ℹ Info del Ámbito: #{ambito['nombre']}#{Color::RESET}\n\n"
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{ambito['id']}"
|
||||
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{ambito['nombre']}"
|
||||
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{ambito['descripcion'] || '(sin descripción)'}"
|
||||
puts " #{Color::BOLD}Padre:#{Color::RESET} #{ambito['parent_nombre'] || '(raíz)'}"
|
||||
puts " #{Color::BOLD}Activo:#{Color::RESET} #{ambito['activo'] ? 'Sí' : 'No'}"
|
||||
puts " #{Color::BOLD}Nodos:#{Color::RESET} #{ambito['count_nodos']}"
|
||||
puts ""
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: ambito:nodos [NOMBRE_O_ID] - Listar nodos de un ámbito
|
||||
def comando_ambito_nodos(args)
|
||||
objetivo = args.first
|
||||
if objetivo.nil? || objetivo.start_with?('-')
|
||||
puts "#{Color::YELLOW}Uso: adn db ambito:nodos <nombre_o_id>#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambito = db.get_ambito(objetivo)
|
||||
if ambito.nil?
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{objetivo}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🖥️ Nodos del ámbito '#{ambito['nombre']}'...#{Color::RESET}\n\n"
|
||||
nodos = db.get_nodos_by_ambito(ambito['id'])
|
||||
|
||||
if nodos.empty?
|
||||
puts "#{Color::YELLOW}⚠ Este ámbito no tiene nodos asignados.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
nodos.each do |n|
|
||||
tipo = n['tipo'].to_s.ljust(10)
|
||||
ip = (n['ip'] || '-').ljust(15)
|
||||
estado = n['activo'] ? "#{Color::GREEN}ON#{Color::RESET}" : "#{Color::RED}OFF#{Color::RESET}"
|
||||
puts " #{Color::DIM}▸#{Color::RESET} #{Color::BOLD}#{n['nombre'].to_s.ljust(20)}#{Color::RESET} [#{tipo}] IP: #{ip} (#{estado})"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,13 +2,16 @@ module ADN
|
||||
module SubcomandoDBBitacora
|
||||
# Comando: bitacora:crear [FECHA] - Crear nueva bitácora diaria
|
||||
def comando_bitacora_crear(args)
|
||||
opts = { fecha: Date.today, resumen: nil }
|
||||
opts = { fecha: Date.today, resumen: nil, modo: 'P' }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db bitacora:crear [FECHA] [OPCIONES]"
|
||||
opt.on("--resumen TEXTO", "Resumen inicial de la bitácora") do |r|
|
||||
opts[:resumen] = r
|
||||
end
|
||||
opt.on("--modo MODO", "Modo de la jornada: P=Presencial, R=Remoto (default: P)") do |m|
|
||||
opts[:modo] = m.upcase
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
@@ -17,7 +20,6 @@ module ADN
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
# Si queda un argumento sin procesar, asumimos que es la fecha
|
||||
opts[:fecha] = Date.parse(args[0]) if args[0]
|
||||
rescue OptionParser::InvalidOption, Date::Error => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
@@ -29,11 +31,12 @@ module ADN
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
bitacora = db.create_bitacora(fecha: opts[:fecha], resumen: opts[:resumen])
|
||||
bitacora = db.create_bitacora(fecha: opts[:fecha], resumen: opts[:resumen], modo: opts[:modo])
|
||||
puts "#{Color::GREEN}✅ Bitácora creada exitosamente#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{bitacora['id']}"
|
||||
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{bitacora['fecha']}"
|
||||
puts " #{Color::BOLD}Modo:#{Color::RESET} #{bitacora['modo']} (#{modo_label(bitacora['modo'])})"
|
||||
puts " #{Color::BOLD}Resumen:#{Color::RESET} #{bitacora['resumen'] || '(sin resumen)'}"
|
||||
puts " #{Color::BOLD}Creada:#{Color::RESET} #{bitacora['created_at']}"
|
||||
rescue PG::UniqueViolation
|
||||
@@ -91,16 +94,17 @@ module ADN
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Fecha".ljust(12)} #{"Resumen".ljust(40)} #{"Creada".ljust(20)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 12} #{"─" * 40} #{"─" * 20}"
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Fecha".ljust(12)} #{"Modo"} #{"Resumen".ljust(38)} #{"Creada".ljust(20)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 12} #{"─" * 4} #{"─" * 38} #{"─" * 20}"
|
||||
|
||||
bitacoras.each do |b|
|
||||
fecha = b['fecha']
|
||||
modo = b['modo'] || 'P'
|
||||
resumen = b['resumen'] || ''
|
||||
resumen = resumen[0..37] + '...' if resumen.length > 40
|
||||
resumen = resumen[0..35] + '...' if resumen.length > 38
|
||||
creada = Time.parse(b['created_at']).strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
puts "#{b['id'].to_s.ljust(5)} #{fecha.ljust(12)} #{resumen.ljust(40)} #{creada.ljust(20)}"
|
||||
puts "#{b['id'].to_s.ljust(5)} #{fecha.ljust(12)} [#{modo}] #{resumen.ljust(38)} #{creada.ljust(20)}"
|
||||
end
|
||||
|
||||
puts ""
|
||||
@@ -108,5 +112,8 @@ module ADN
|
||||
end
|
||||
end
|
||||
|
||||
def modo_label(modo)
|
||||
{ 'P' => 'Presencial', 'R' => 'Remoto', 'S' => 'Servicio' }[modo] || modo
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+134
-32
@@ -10,18 +10,22 @@ module ADN
|
||||
modo: nil,
|
||||
es_ia: false,
|
||||
nodo: nil,
|
||||
tema: nil
|
||||
tema: nil,
|
||||
plantilla: nil,
|
||||
fecha: nil,
|
||||
ambito: nil,
|
||||
doc_path: nil
|
||||
}
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db evento:crear [OPCIONES]"
|
||||
opt.on("--inicio HH:MM", "Hora de inicio (requerido)") do |i|
|
||||
opt.on("--inicio HH:MM[:SS]", "Hora de inicio (requerido)") do |i|
|
||||
opts[:inicio] = i
|
||||
end
|
||||
opt.on("--descripcion TEXTO", "Descripción de la actividad (requerido)") do |d|
|
||||
opts[:descripcion] = d
|
||||
end
|
||||
opt.on("--fin HH:MM", "Hora de fin (opcional)") do |f|
|
||||
opt.on("--fin HH:MM[:SS]", "Hora de fin (opcional)") do |f|
|
||||
opts[:fin] = f
|
||||
end
|
||||
opt.on("--estado ESTADO", "Estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
|
||||
@@ -33,9 +37,21 @@ module ADN
|
||||
opt.on("--nodo NOMBRE", "Nombre del nodo (servidor/VM)") do |n|
|
||||
opts[:nodo] = n
|
||||
end
|
||||
opt.on("--fecha YYYY-MM-DD", "Fecha de la bitácora (default: hoy)") do |f|
|
||||
opts[:fecha] = f
|
||||
end
|
||||
opt.on("--ambito NOMBRE_O_ID", "Ámbito del evento") do |a|
|
||||
opts[:ambito] = a
|
||||
end
|
||||
opt.on("--doc RUTA", "Ruta al plan .md (ej: P2601_Dasuten/P2601.09_DASUTEN-sin-DC.md)") do |d|
|
||||
opts[:doc_path] = d
|
||||
end
|
||||
opt.on("--ia", "Marcar como generado por IA") do
|
||||
opts[:es_ia] = true
|
||||
end
|
||||
opt.on("--plantilla TIPO", "Usar plantilla (implementacion, investigacion, resolucion, documentacion, test, debug, planificacion)") do |p|
|
||||
opts[:plantilla] = p
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
@@ -51,12 +67,32 @@ module ADN
|
||||
end
|
||||
|
||||
# Validaciones básicas
|
||||
unless opts[:inicio] && opts[:descripcion]
|
||||
puts "#{Color::RED}✗ Error: --inicio y --descripcion son requeridos#{Color::RESET}"
|
||||
unless opts[:descripcion]
|
||||
puts "#{Color::RED}✗ Error: --descripcion es requerido#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Si no se especifica --inicio, usar hora actual
|
||||
opts[:inicio] ||= Time.now.strftime('%H:%M:%S')
|
||||
puts "#{Color::DIM}ℹ Hora de inicio: #{opts[:inicio]}#{Color::RESET}"
|
||||
|
||||
# Si se specify plantilla, mostrar y prependear al texto
|
||||
if opts[:plantilla]
|
||||
require_relative '../../templates/plantillas_eventos'
|
||||
if ADN::PlantillasEventos.existe?(opts[:plantilla])
|
||||
plantilla = ADN::PlantillasEventos.obtener(opts[:plantilla])
|
||||
puts "#{Color::CYAN}📋 Plantilla: #{opts[:plantilla]}#{Color::RESET}"
|
||||
puts plantilla
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}💡 Completa la plantilla y usa --descripcion con el resultado#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::RED}✗ Tipo de plantilla desconocido: #{opts[:plantilla]}#{Color::RESET}"
|
||||
puts "#{Color::YELLOW}Disponibles: #{ADN::PlantillasEventos.lista_tipos.join(', ')}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Obtener modo desde jornada actual si no se especifica
|
||||
modo_resuelto = opts[:modo]
|
||||
if modo_resuelto.nil?
|
||||
@@ -79,24 +115,44 @@ module ADN
|
||||
end
|
||||
end
|
||||
|
||||
# Buscar bitácora para hoy (o crearla)
|
||||
fecha_hoy = Date.today.to_s
|
||||
bitacora = db.find_bitacora_by_fecha(fecha_hoy)
|
||||
# Resolver ámbito si se especificó
|
||||
ambito_id = nil
|
||||
if opts[:ambito]
|
||||
ambito = db.get_ambito(opts[:ambito])
|
||||
if ambito
|
||||
ambito_id = ambito['id']
|
||||
puts " #{Color::DIM}Ámbito encontrado: #{opts[:ambito]} (ID: #{ambito_id})#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Ámbito '#{opts[:ambito]}' no encontrado#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# Determinar fecha: --fecha o hoy
|
||||
fecha_target = opts[:fecha] ? opts[:fecha] : Date.today.to_s
|
||||
if opts[:fecha]
|
||||
begin
|
||||
Date.parse(opts[:fecha])
|
||||
puts " #{Color::DIM}ℹ Fecha especificada: #{fecha_target}#{Color::RESET}"
|
||||
rescue Date::Error
|
||||
puts "#{Color::RED}✗ Formato de fecha inválido: #{opts[:fecha]}. Usar YYYY-MM-DD#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
bitacora = db.find_bitacora_by_fecha(fecha_target)
|
||||
|
||||
if bitacora.nil?
|
||||
puts " #{Color::DIM}Creando bitácora para hoy...#{Color::RESET}"
|
||||
bitacora = db.create_bitacora(fecha: fecha_hoy)
|
||||
puts " #{Color::DIM}Creando bitácora para #{fecha_target}...#{Color::RESET}"
|
||||
bitacora = db.create_bitacora(fecha: fecha_target)
|
||||
end
|
||||
|
||||
bitacora_id = bitacora['id']
|
||||
|
||||
# Obtener modo desde jornada actual si no se especificó
|
||||
if modo_resuelto.nil?
|
||||
entradas = db.list_entradas(bitacora_id: bitacora_id, limit: 10)
|
||||
jornada = entradas.find { |e| e['descripcion']&.downcase&.include?('inicio de jornada') }
|
||||
if jornada && jornada['modo']
|
||||
modo_resuelto = jornada['modo']
|
||||
puts " #{Color::DIM}ℹ Modo resuelto desde jornada: #{modo_resuelto == 'P' ? 'Presencial' : 'Remoto'}#{Color::RESET}"
|
||||
if bitacora && bitacora['modo']
|
||||
modo_resuelto = bitacora['modo']
|
||||
puts " #{Color::DIM}ℹ Modo resuelto desde jornada: #{modo_resuelto == 'P' ? 'Presencial' : (modo_resuelto == 'R' ? 'Remoto' : 'Servicio')}#{Color::RESET}"
|
||||
else
|
||||
modo_resuelto = 'P' # default
|
||||
puts " #{Color::DIM}ℹ Sin jornada activa, usando default: Presencial#{Color::RESET}"
|
||||
@@ -128,7 +184,20 @@ module ADN
|
||||
tema_id: nil # Por ahora sin tema
|
||||
)
|
||||
|
||||
# Asignar doc_path si se especificó
|
||||
if opts[:doc_path] && entrada
|
||||
db.update_entrada(entrada['id'], { doc_path: opts[:doc_path] })
|
||||
puts " #{Color::DIM}ℹ Doc vinculado: #{opts[:doc_path]}#{Color::RESET}"
|
||||
end
|
||||
|
||||
# Asignar ámbito si se especificó
|
||||
if ambito_id && entrada
|
||||
db.update_entrada(entrada['id'], { ambito_id: ambito_id })
|
||||
puts " #{Color::DIM}ℹ Ámbito asignado (ID: #{ambito_id})#{Color::RESET}"
|
||||
end
|
||||
|
||||
puts "#{Color::GREEN}✅ Entrada creada exitosamente#{Color::RESET}"
|
||||
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{fecha_target}"
|
||||
puts " #{Color::BOLD}Inicio:#{Color::RESET} #{entrada['inicio']}"
|
||||
puts " #{Color::BOLD}Fin:#{Color::RESET} #{entrada['fin'] || '(sin fin)'}"
|
||||
puts " 📝 #{Color::BOLD}Descripción:#{Color::RESET} #{entrada['descripcion']}"
|
||||
@@ -141,7 +210,7 @@ module ADN
|
||||
def comando_evento_actualizar(args)
|
||||
if args.length < 1 || args[0].start_with?('-')
|
||||
puts "#{Color::YELLOW}Uso: adn db evento:actualizar <ID> [OPCIONES]#{Color::RESET}"
|
||||
puts "Opciones soportadas: --fin \"HH:MM\", --inicio \"HH:MM\", --estado \"✅\", --descripcion \"Texto\", --nodo \"Nombre\""
|
||||
puts "Opciones soportadas: --fin \"HH:MM[:SS]\", --inicio \"HH:MM[:SS]\", --estado \"✅\", --descripcion \"Texto\", --nodo \"Nombre\""
|
||||
return
|
||||
end
|
||||
|
||||
@@ -150,12 +219,14 @@ module ADN
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Opciones:"
|
||||
opt.on("--inicio HH:MM", "Nueva hora de inicio") { |i| opts[:inicio] = i }
|
||||
opt.on("--fin HH:MM", "Nueva hora de fin") { |f| opts[:fin] = f }
|
||||
opt.on("--inicio HH:MM[:SS]", "Nueva hora de inicio") { |i| opts[:inicio] = i }
|
||||
opt.on("--fin HH:MM[:SS]", "Nueva hora de fin") { |f| opts[:fin] = f }
|
||||
opt.on("--descripcion TEXTO", "Nueva descripción") { |d| opts[:descripcion] = d }
|
||||
opt.on("--estado ESTADO", "Nuevo estado (⏳, ✅, ❌, ⚠️, ⏸️)") { |e| opts[:estado] = e }
|
||||
opt.on("--modo MODO", "Modo (P=Presencial, R=Remoto)") { |m| opts[:modo] = m }
|
||||
opt.on("--nodo NOMBRE", "Nuevo nodo") { |n| opts[:nodo_raw] = n }
|
||||
opt.on("--ambito NOMBRE_O_ID", "Nuevo ámbito") { |a| opts[:ambito_raw] = a }
|
||||
opt.on("--doc RUTA", "Ruta al plan .md vinculado") { |d| opts[:doc_path] = d }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
@@ -199,6 +270,20 @@ module ADN
|
||||
opts.delete(:nodo_raw)
|
||||
end
|
||||
|
||||
if opts.key?(:ambito_raw)
|
||||
if opts[:ambito_raw].nil? || opts[:ambito_raw].empty?
|
||||
opts[:ambito_id] = nil
|
||||
else
|
||||
ambito = db.get_ambito(opts[:ambito_raw])
|
||||
if ambito
|
||||
opts[:ambito_id] = ambito['id']
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Ámbito '#{opts[:ambito_raw]}' no encontrado#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
opts.delete(:ambito_raw)
|
||||
end
|
||||
|
||||
begin
|
||||
res = db.update_entrada(id_evento, opts)
|
||||
if res
|
||||
@@ -232,6 +317,9 @@ module ADN
|
||||
opt.on("--nodo NOMBRE", "Filtrar por nombre de nodo") do |n|
|
||||
opts[:nodo] = n
|
||||
end
|
||||
opt.on("--ambito NOMBRE_O_ID", "Filtrar por ámbito") do |a|
|
||||
opts[:ambito] = a
|
||||
end
|
||||
opt.on("--estado ESTADO", "Filtrar por estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
|
||||
opts[:estado] = e
|
||||
end
|
||||
@@ -276,7 +364,20 @@ module ADN
|
||||
end
|
||||
end
|
||||
|
||||
# Obtener ID de ámbito si se especificó nombre
|
||||
ambito_id = nil
|
||||
if opts[:ambito]
|
||||
ambito = db.get_ambito(opts[:ambito])
|
||||
if ambito
|
||||
ambito_id = ambito['id']
|
||||
puts " #{Color::DIM}Filtrando por ámbito: #{opts[:ambito]} (ID: #{ambito_id})#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Ámbito '#{opts[:ambito]}' no encontrado#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
entradas = db.list_entradas(
|
||||
ambito_id: ambito_id,
|
||||
nodo_id: nodo_id,
|
||||
estado: opts[:estado],
|
||||
buscar: opts[:buscar],
|
||||
@@ -296,9 +397,9 @@ module ADN
|
||||
entradas.each_with_index do |e, i|
|
||||
puts "#{Color::DIM}#{"─" * 60}#{Color::RESET}" if i > 0
|
||||
id = e['id'].to_s
|
||||
inicio = e['inicio'][0..7]
|
||||
fin = e['fin'] ? e['fin'][0..7] : '--:--'
|
||||
nodo = e['nodo_nombre'] || '(sin nodo)'
|
||||
inicio = e['inicio'] ? e['inicio'][0..7] : '--:--:--'
|
||||
fin = e['fin'] ? e['fin'][0..7] : '--:--:--'
|
||||
nodo = "#{e['ambito_nombre'] || '-'} / #{e['nodo_nombre'] || '-'}"
|
||||
estado = e['estado']
|
||||
desc = e['descripcion']
|
||||
|
||||
@@ -306,22 +407,23 @@ module ADN
|
||||
puts " #{desc}"
|
||||
end
|
||||
else
|
||||
# Formato tabla compacta (original)
|
||||
# Formato tabla compacta (original ajustado)
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(8)} #{"Fin".ljust(8)} #{"Nodo".ljust(15)} #{"Estado".ljust(3)} #{"Descripción".ljust(40)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 8} #{"─" * 8} #{"─" * 15} #{"─" * 3} #{"─" * 40}"
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(9)} #{"Fin".ljust(9)} #{"Ámbito/Nodo".ljust(25)} #{"Est".ljust(3)} #{"Descripción".ljust(35)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 9} #{"─" * 9} #{"─" * 25} #{"─" * 3} #{"─" * 35}"
|
||||
|
||||
entradas.each do |e|
|
||||
id = e['id'].to_s
|
||||
inicio = e['inicio'][0..7] # HH:MM:SS -> HH:MM
|
||||
inicio = e['inicio'] ? e['inicio'][0..7] : '--:--'
|
||||
fin = e['fin'] ? e['fin'][0..7] : '--:--'
|
||||
nodo = e['nodo_nombre'] || '(sin nodo)'
|
||||
nodo = nodo[0..13] + '...' if nodo.length > 15
|
||||
nodo = "#{e['ambito_nombre'] || '-'} / #{e['nodo_nombre'] || '-'}"
|
||||
nodo = nodo[0..23] + '...' if nodo.length > 25
|
||||
estado = e['estado']
|
||||
desc = e['descripcion']
|
||||
desc = desc[0..37] + '...' if desc.length > 40
|
||||
desc = e['descripcion'] || ''
|
||||
desc = desc.split("\n").first || '' # Tomar solo la primera línea
|
||||
desc = desc[0..32] + '...' if desc.length > 35
|
||||
|
||||
puts "#{id.ljust(5)} #{inicio.ljust(8)} #{fin.ljust(8)} #{nodo.ljust(15)} #{estado.ljust(3)} #{desc.ljust(40)}"
|
||||
puts "#{id.ljust(5)} #{inicio.ljust(9)} #{fin.ljust(9)} #{nodo.ljust(25)} #{estado.ljust(3)} #{desc.ljust(35)}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -397,13 +499,13 @@ module ADN
|
||||
|
||||
entradas.each do |e|
|
||||
id = e['id'].to_s
|
||||
inicio = e['inicio'][0..4] # HH:MM:SS -> HH:MM
|
||||
inicio = e['inicio'] ? e['inicio'][0..7] : '--:--:--' # HH:MM:SS
|
||||
nodo = (e['nodo_nombre'] || '(sin nodo)')[0..14]
|
||||
desc = e['descripcion']
|
||||
desc = desc[0..47] + '...' if desc.length > 50
|
||||
estado_icon = e['estado']
|
||||
|
||||
puts " #{estado_icon} #{Color::YELLOW}ID #{id.ljust(5)}#{Color::RESET} #{inicio.ljust(5)} #{nodo.ljust(15)} #{desc}"
|
||||
puts " #{estado_icon} #{Color::YELLOW}ID #{id.ljust(5)}#{Color::RESET} #{inicio.ljust(11)} #{nodo.ljust(15)} #{desc}"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -86,8 +86,9 @@ module ADN
|
||||
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{porcentaje_completadas}%"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def comando_backup(args)
|
||||
def comando_backup(args)
|
||||
opts = { output: nil, compress: true, timestamp: true }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
@@ -185,10 +186,45 @@ module ADN
|
||||
end
|
||||
end
|
||||
|
||||
def comando_migrar_sql(args)
|
||||
opts = {}
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db migrar:sql ARCHIVO_SQL"
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
sql_file = args.first
|
||||
if sql_file.nil? || !File.exist?(sql_file)
|
||||
puts "#{Color::RED}✗ Error: Archivo SQL no especificado o no encontrado.#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Ejecutando script SQL: #{sql_file}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
sql = File.read(sql_file)
|
||||
db.send(:execute, sql)
|
||||
puts "#{Color::GREEN}✅ Script SQL ejecutado exitosamente.#{Color::RESET}"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error ejecutando SQL: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
# Comando: archivar:md - Mover bitácoras y proyectos MD importados/obsoletos a carpetas _hist
|
||||
def comando_archivar_md
|
||||
require 'fileutils'
|
||||
|
||||
+121
-5
@@ -21,6 +21,9 @@ module ADN
|
||||
opt.on("--inactivo", "Crear nodo como inactivo") do
|
||||
opts[:activo] = false
|
||||
end
|
||||
opt.on("--ambito NOMBRE_O_ID", "Ámbito Padre (Opcional)") do |a|
|
||||
opts[:ambito] = a
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
@@ -45,18 +48,30 @@ module ADN
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
ambito_id = nil
|
||||
if opts[:ambito]
|
||||
ambito = db.get_ambito(opts[:ambito])
|
||||
if ambito.nil?
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{opts[:ambito]}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
ambito_id = ambito['id']
|
||||
end
|
||||
|
||||
nodo = db.create_nodo(
|
||||
nombre: opts[:nombre],
|
||||
ip: opts[:ip],
|
||||
tipo: opts[:tipo],
|
||||
descripcion: opts[:descripcion],
|
||||
activo: opts[:activo]
|
||||
activo: opts[:activo],
|
||||
ambito_id: ambito_id
|
||||
)
|
||||
|
||||
puts "#{Color::GREEN}✅ Nodo creado exitosamente#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{nodo['id']}"
|
||||
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{nodo['nombre']}"
|
||||
puts " #{Color::BOLD}Ámbito:#{Color::RESET} #{opts[:ambito] ? ambito['nombre'] : '(sin ámbito)'}"
|
||||
puts " #{Color::BOLD}IP:#{Color::RESET} #{nodo['ip'] || '(sin IP)'}"
|
||||
puts " #{Color::BOLD}Tipo:#{Color::RESET} #{nodo['tipo']}"
|
||||
puts " #{Color::BOLD}Activo:#{Color::RESET} #{nodo['activo'] ? 'Sí' : 'No'}"
|
||||
@@ -86,6 +101,9 @@ module ADN
|
||||
opt.on("--tipo TIPO", "Filtrar por tipo (servidor, hipervisor, vm, pc)") do |t|
|
||||
opts[:tipo] = t
|
||||
end
|
||||
opt.on("--ambito NOMBRE_O_ID", "Filtrar por ámbito") do |a|
|
||||
opts[:ambito] = a
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
@@ -103,7 +121,18 @@ module ADN
|
||||
puts "#{Color::CYAN}🖥️ Listando nodos...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
nodos = db.list_nodos(activo: opts[:activo], tipo: opts[:tipo], limit: 100)
|
||||
ambito_id = nil
|
||||
if opts[:ambito]
|
||||
ambito = db.get_ambito(opts[:ambito])
|
||||
if ambito
|
||||
ambito_id = ambito['id']
|
||||
else
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{opts[:ambito]}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
nodos = db.list_nodos(activo: opts[:activo], tipo: opts[:tipo], ambito_id: ambito_id, limit: 100)
|
||||
|
||||
if nodos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron nodos#{Color::RESET}"
|
||||
@@ -111,19 +140,21 @@ module ADN
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"IP".ljust(15)} #{"Tipo".ljust(12)} #{"Activo".ljust(6)} #{"Descripción".ljust(30)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 20} #{"─" * 15} #{"─" * 12} #{"─" * 6} #{"─" * 30}"
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"Ámbito".ljust(15)} #{"IP".ljust(15)} #{"Tipo".ljust(12)} #{"Activo".ljust(6)} #{"Descripción".ljust(30)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 20} #{"─" * 15} #{"─" * 15} #{"─" * 12} #{"─" * 6} #{"─" * 30}"
|
||||
|
||||
nodos.each do |n|
|
||||
id = n['id'].to_s
|
||||
nombre = n['nombre']
|
||||
ambito_n = (n['ambito_nombre'] || '-').to_s
|
||||
ambito_n = ambito_n[0..12] + '...' if ambito_n.length > 15
|
||||
ip = n['ip'] || '(sin IP)'
|
||||
tipo = n['tipo']
|
||||
activo = n['activo'] ? '✅' : '❌'
|
||||
desc = n['descripcion'] || ''
|
||||
desc = desc[0..27] + '...' if desc.length > 30
|
||||
|
||||
puts "#{id.ljust(5)} #{nombre.ljust(20)} #{ip.ljust(15)} #{tipo.ljust(12)} #{activo.ljust(6)} #{desc.ljust(30)}"
|
||||
puts "#{id.ljust(5)} #{nombre.ljust(20)} #{ambito_n.ljust(15)} #{ip.ljust(15)} #{tipo.ljust(12)} #{activo.ljust(6)} #{desc.ljust(30)}"
|
||||
end
|
||||
|
||||
puts ""
|
||||
@@ -135,5 +166,90 @@ module ADN
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: nodo:actualizar NOMBRE [OPCIONES] - Actualizar nodo existente
|
||||
def comando_nodo_actualizar(args)
|
||||
return if args.empty?
|
||||
|
||||
nombre = args.shift
|
||||
opts = { ip: nil, tipo: nil, descripcion: nil, activo: nil, ambito: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db nodo:actualizar NOMBRE [OPCIONES]"
|
||||
opt.on("--ip DIRECCION", "Nueva dirección IP") do |i|
|
||||
opts[:ip] = i
|
||||
end
|
||||
opt.on("--tipo TIPO", "Nuevo tipo (servidor, hipervisor, vm, pc)") do |t|
|
||||
opts[:tipo] = t
|
||||
end
|
||||
opt.on("--descripcion TEXTO", "Nueva descripción") do |d|
|
||||
opts[:descripcion] = d
|
||||
end
|
||||
opt.on("--activo", "Marcar como activo") do
|
||||
opts[:activo] = true
|
||||
end
|
||||
opt.on("--inactivo", "Marcar como inactivo") do
|
||||
opts[:activo] = false
|
||||
end
|
||||
opt.on("--ambito NOMBRE_O_ID", "Nuevo ámbito") do |a|
|
||||
opts[:ambito] = a
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando nodo '#{nombre}'...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
ambito_id = nil
|
||||
if opts[:ambito]
|
||||
ambito = db.get_ambito(opts[:ambito])
|
||||
if ambito.nil?
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{opts[:ambito]}' no encontrado.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
ambito_id = ambito['id']
|
||||
end
|
||||
|
||||
nodo = db.update_nodo(
|
||||
nombre: nombre,
|
||||
ip: opts[:ip],
|
||||
tipo: opts[:tipo],
|
||||
descripcion: opts[:descripcion],
|
||||
activo: opts[:activo],
|
||||
ambito_id: ambito_id
|
||||
)
|
||||
|
||||
if nodo.nil?
|
||||
puts "#{Color::YELLOW}⚠ No se encontró el nodo '#{nombre}'#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::GREEN}✅ Nodo actualizado exitosamente#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{nodo['id']}"
|
||||
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{nodo['nombre']}"
|
||||
puts " #{Color::BOLD}Ámbito:#{Color::RESET} #{ambito_id ? opts[:ambito] : '(sin ámbito)'}"
|
||||
puts " #{Color::BOLD}IP:#{Color::RESET} #{nodo['ip'] || '(sin IP)'}"
|
||||
puts " #{Color::BOLD}Tipo:#{Color::RESET} #{nodo['tipo']}"
|
||||
puts " #{Color::BOLD}Activo:#{Color::RESET} #{nodo['activo'] ? 'Sí' : 'No'}"
|
||||
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{nodo['descripcion'] || '(sin descripción)'}"
|
||||
puts " #{Color::BOLD}Actualizado:#{Color::RESET} #{nodo['updated_at']}"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error actualizando nodo: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,6 +29,8 @@ class SubcomandoGenerar
|
||||
generar_nodo
|
||||
when 'proyecto'
|
||||
generar_proyecto
|
||||
when 'evento'
|
||||
generar_evento
|
||||
else
|
||||
@logger.error("Tipo de generación desconocido: #{tipo}")
|
||||
mostrar_ayuda_generar
|
||||
@@ -46,13 +48,14 @@ class SubcomandoGenerar
|
||||
opciones: [
|
||||
{ names: ['bitacora [fecha]'], desc: "Crea nueva bitácora (fecha: YYYY-MM-DD, default: hoy)" },
|
||||
{ names: ['nodo <nombre>'], desc: "Crea ficha de nodo" },
|
||||
{ names: ['proyecto <código>'], desc: "Crea manifiesto de proyecto" }
|
||||
{ names: ['proyecto <código>'], desc: "Crea manifiesto de proyecto" },
|
||||
{ names: ['evento <tipo>'], desc: "Crea plantilla de detalle para evento" }
|
||||
],
|
||||
ejemplos: [
|
||||
{ cmd: "./adn/tools/run generar bitacora", desc: "Crear bitácora para hoy" },
|
||||
{ cmd: "./adn/tools/run generar bitacora 2026-03-08", desc: "Crear bitácora para el 8 de marzo de 2026" },
|
||||
{ cmd: "./adn/tools/run generar nodo srv-nuevo", desc: "Crear ficha para el nodo srv-nuevo" },
|
||||
{ cmd: "./adn/tools/run generar proyecto P2604", desc: "Crear manifiesto para el proyecto P2604" }
|
||||
{ cmd: "./adn/tools/run generar proyecto P2604", desc: "Crear manifiesto para el proyecto P2604" },
|
||||
{ cmd: "./adn/tools/run generar evento implementacion", desc: "Ver plantilla de implementación" }
|
||||
]
|
||||
)
|
||||
|
||||
@@ -250,4 +253,31 @@ class SubcomandoGenerar
|
||||
@logger.exito("Manifiesto de proyecto creado: #{codigo}")
|
||||
puts "#{Color::GREEN}✓ Manifiesto creado en: #{ruta_proyecto}#{Color::RESET}"
|
||||
end
|
||||
|
||||
def generar_evento
|
||||
require_relative '../templates/plantillas_eventos'
|
||||
|
||||
tipo = @args[0]
|
||||
if tipo.nil? || tipo.empty?
|
||||
puts "#{Color::CYAN}📋 Tipos de plantillas de eventos disponibles:#{Color::RESET}"
|
||||
puts ""
|
||||
ADN::PlantillasEventos.lista_tipos.each do |t|
|
||||
puts " #{Color::GREEN}#{t}#{Color::RESET}"
|
||||
end
|
||||
puts ""
|
||||
puts "Uso: #{Color::DIM}./adn/tools/run generar evento <tipo>#{Color::RESET}"
|
||||
puts "Ejemplo: #{Color::DIM}./adn/tools/run generar evento implementacion#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
if ADN::PlantillasEventos.existe?(tipo)
|
||||
puts "#{Color::CYAN}📝 Plantilla: #{tipo}#{Color::RESET}"
|
||||
puts ""
|
||||
puts ADN::PlantillasEventos.obtener(tipo)
|
||||
else
|
||||
puts "#{Color::RED}✗ Tipo de plantilla desconocido: #{tipo}#{Color::RESET}"
|
||||
puts "#{Color::YELLOW}Tipos disponibles:#{Color::RESET}"
|
||||
ADN::PlantillasEventos.lista_tipos.each { |t| puts " - #{t}" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -148,6 +148,9 @@ module ADN
|
||||
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{fecha}"
|
||||
@logger.exito("Jornada #{tipo_str} iniciada en Base de Datos.")
|
||||
end
|
||||
|
||||
# 4. Verificar y mostrar URL de bitácora web
|
||||
verificar_y_mostrar_bitacora_web
|
||||
end
|
||||
|
||||
def ejecutar_cierre
|
||||
@@ -233,5 +236,33 @@ module ADN
|
||||
puts "#{Color::DIM}#{'-' * 50}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
def verificar_y_mostrar_bitacora_web
|
||||
@logger.info("Verificando estado de bitácora web...")
|
||||
|
||||
# Verificar si los servicios Docker están activos
|
||||
begin
|
||||
output = `docker compose -f servicios/nginx/dtic-bitacoras/docker-compose.yml ps 2>&1`
|
||||
# Docker muestra "Up" cuando los servicios están activos
|
||||
servicios_activos = output.include?('Up')
|
||||
|
||||
if servicios_activos
|
||||
puts ""
|
||||
puts "#{Color::CYAN}🌐 Bitácora Web disponible:#{Color::RESET}"
|
||||
puts " #{Color::GREEN}✓ Dashboard local:#{Color::RESET} http://localhost:5174"
|
||||
puts " #{Color::GREEN}✓ Acceso remoto:#{Color::RESET} http://srv-ns8/bitacoras"
|
||||
@logger.exito("Bitácora web verificada y disponible")
|
||||
else
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Bitácora Web:#{Color::RESET}"
|
||||
puts " Servicios Docker no están activos. Ejecutar:"
|
||||
puts " #{Color::DIM}docker compose -f servicios/nginx/dtic-bitacoras/docker-compose.yml up -d#{Color::RESET}"
|
||||
@logger.warn("Bitácora web: servicios no activos")
|
||||
end
|
||||
rescue StandardError => e
|
||||
@logger.error("Error verificando bitácora web: #{e.message}")
|
||||
puts "#{Color::RED}✗ Error verificando servicios: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+74
-7
@@ -54,14 +54,14 @@ module ADN
|
||||
descripcion: "Gestión y consulta de nodos de infraestructura",
|
||||
uso: "./adn/tools/run nodos <acción> [opciones]",
|
||||
opciones: [
|
||||
{ names: ['agrupar [--servidor SRV]'], desc: "Agrupar VMs por servidor anfitrión" },
|
||||
{ names: ['listar [--tipo TIPO]'], desc: "Listar fichas de nodos (.md)" },
|
||||
{ names: ['agrupar [--por ambitos|servidor]'], desc: "Agrupar nodos de la red" },
|
||||
{ names: ['listar [--tipo TIPO] [--ambito AMB]'], desc: "Listar fichas de nodos (.md)" },
|
||||
{ names: ['buscar <término>'], desc: "Buscar nodos por nombre o contenido" }
|
||||
],
|
||||
ejemplos: [
|
||||
{ cmd: "./adn/tools/run nodos agrupar", desc: "Agrupar todos los VMs por servidor" },
|
||||
{ cmd: "./adn/tools/run nodos agrupar --servidor srv-pmox1", desc: "Agrupar VMs del servidor srv-pmox1" },
|
||||
{ cmd: "./adn/tools/run nodos listar --tipo srvv", desc: "Listar nodos de tipo srvv" },
|
||||
{ cmd: "./adn/tools/run nodos agrupar --por ambitos", desc: "Agrupar nodos estructurados desde la DB" },
|
||||
{ cmd: "./adn/tools/run nodos agrupar --por servidor --servidor srv-pmox1", desc: "Agrupar VMs del servidor en base a fichas" },
|
||||
{ cmd: "./adn/tools/run nodos listar --ambito dtic-DASUTEN", desc: "Listar nodos MD cruzados con ámbito DB" },
|
||||
{ cmd: "./adn/tools/run nodos buscar dasuten", desc: "Buscar nodos relacionados con dasuten" }
|
||||
]
|
||||
)
|
||||
@@ -110,12 +110,59 @@ module ADN
|
||||
# ─── Agrupar ────────────────────────────────────────────────────────
|
||||
|
||||
def agrupar_nodos
|
||||
opts = { servidor: nil }
|
||||
opts = { servidor: nil, por: 'servidor' }
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on('--servidor SRV', 'Filtrar por servidor anfitrión') { |s| opts[:servidor] = s }
|
||||
opt.on('--por CAMPO', 'Agrupar por: servidor, ambitos') { |c| opts[:por] = c }
|
||||
end
|
||||
parser.order!(@args)
|
||||
|
||||
if opts[:por] == 'ambitos'
|
||||
agrupar_por_ambitos
|
||||
else
|
||||
agrupar_por_servidor(opts)
|
||||
end
|
||||
end
|
||||
|
||||
def agrupar_por_ambitos
|
||||
puts "#{Color::CYAN}📋 Agrupando nodos por Ámbitos (Desde DB)#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
ambitos_hash = Hash.new { |h, k| h[k] = [] }
|
||||
sin_ambito = []
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
nodos = db.list_nodos(limit: 1000)
|
||||
nodos.each do |n|
|
||||
if n['ambito_nombre']
|
||||
ambitos_hash[n['ambito_nombre']] << n['nombre']
|
||||
else
|
||||
sin_ambito << n['nombre']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ambitos_hash.keys.sort.each do |amb|
|
||||
vms = ambitos_hash[amb]
|
||||
puts "#{Color::GREEN}🏢 #{amb}#{Color::RESET} (#{vms.length} nodos)"
|
||||
vms.sort.each { |vm| puts " ├── 💻 #{vm}" }
|
||||
puts ""
|
||||
end
|
||||
|
||||
if sin_ambito.any?
|
||||
puts "#{Color::YELLOW}❓ Nodos sin ámbito asignado:#{Color::RESET}"
|
||||
sin_ambito.sort.each { |nodo| puts " ├── 💻 #{nodo}" }
|
||||
puts ""
|
||||
end
|
||||
|
||||
total_vms = ambitos_hash.values.flatten.length + sin_ambito.length
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Total: #{ambitos_hash.keys.length} ámbitos, #{total_vms} nodos#{Color::RESET}"
|
||||
end
|
||||
|
||||
def agrupar_por_servidor(opts)
|
||||
puts "#{Color::CYAN}📋 Agrupando nodos por Servidor Host#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
@@ -173,14 +220,30 @@ module ADN
|
||||
# ─── Listar ─────────────────────────────────────────────────────────
|
||||
|
||||
def listar_nodos
|
||||
opts = { tipo: nil }
|
||||
opts = { tipo: nil, ambito: nil }
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on('--tipo TIPO', 'Filtrar por prefijo (srv, srvv, pcv, pc, cam, dc, sql)') { |t| opts[:tipo] = t }
|
||||
opt.on('--ambito NOMBRE', 'Cruzar nombres de base de datos para filtrar') { |a| opts[:ambito] = a }
|
||||
end
|
||||
parser.order!(@args)
|
||||
|
||||
puts "#{Color::CYAN}📋 Listando fichas de nodos#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
nodos_validos = nil
|
||||
if opts[:ambito]
|
||||
require_relative '../db/core/bitacora_db'
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambito = db.get_ambito(opts[:ambito])
|
||||
if ambito
|
||||
nodos_db = db.list_nodos(ambito_id: ambito['id'], limit: 1000)
|
||||
nodos_validos = nodos_db.map { |n| n['nombre'] }
|
||||
else
|
||||
puts "#{Color::RED}✗ Error: Ámbito '#{opts[:ambito]}' no encontrado en la DB.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).sort
|
||||
nodos = archivos.map { |a| File.basename(a, '.md') }
|
||||
@@ -189,6 +252,10 @@ module ADN
|
||||
if opts[:tipo]
|
||||
nodos.select! { |n| n.start_with?(opts[:tipo]) }
|
||||
end
|
||||
|
||||
if nodos_validos
|
||||
nodos.select! { |n| nodos_validos.include?(n) }
|
||||
end
|
||||
|
||||
if nodos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron nodos#{opts[:tipo] ? " con prefijo '#{opts[:tipo]}'" : ''}#{Color::RESET}"
|
||||
|
||||
+87
-36
@@ -14,38 +14,41 @@ module ADN
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run salud [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Muestra salud general del sistema ADN"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--dashboard", "Mostrar en formato dashboard (no implementado)") do
|
||||
@opciones[:dashboard] = true
|
||||
end
|
||||
opts.on("--json", "Salida en formato JSON") do
|
||||
@opciones[:json] = true
|
||||
end
|
||||
opts.on("--mejoras", "Incluir sugerencias de mejora continua") do
|
||||
@opciones[:mejoras] = true
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit 1
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit
|
||||
end
|
||||
end
|
||||
opts = { dashboard: false, json: false, mejoras: false, ambito: nil }
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
if @args.include?('-h') || @args.include?('--help') || @args.empty?
|
||||
require_relative '../core/help_formatter'
|
||||
puts ADN::HelpFormatter.generar_help(
|
||||
titulo: "Salud del Sistema ADN",
|
||||
descripcion: "Muestra métricas y estado general del ecosistema (Archivos MD + DB)",
|
||||
uso: "./adn/tools/run salud [opciones]",
|
||||
opciones: [
|
||||
{ names: ['--json'], desc: "Salida en formato JSON" },
|
||||
{ names: ['--mejoras'], desc: "Sugerencias de mejora continua" },
|
||||
{ names: ['--ambito AMBITO'], desc: "Filtrar estadísticas de DB por ámbito" }
|
||||
],
|
||||
ejemplos: [
|
||||
{ cmd: "./adn/tools/run salud", desc: "Reporte general" },
|
||||
{ cmd: "./adn/tools/run salud --ambito dtic-DASUTEN", desc: "Reporte con foco en el ámbito" }
|
||||
]
|
||||
)
|
||||
exit 0
|
||||
end
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.on("--json") { @opciones[:json] = true }
|
||||
opt.on("--mejoras") { @opciones[:mejoras] = true }
|
||||
opt.on("--dashboard") { @opciones[:dashboard] = true }
|
||||
opt.on("--ambito NOMBRE") { |a| @opciones[:ambito] = a }
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts "#{Color::YELLOW}⚠ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
mostrar_salud
|
||||
end
|
||||
@@ -69,23 +72,34 @@ module ADN
|
||||
datos[:sugerencias_mejora] = obtener_sugerencias_mejora
|
||||
end
|
||||
|
||||
datos[:db] = obtener_estadisticas_db(@opciones[:ambito])
|
||||
|
||||
if @opciones[:json]
|
||||
puts JSON.pretty_generate(datos)
|
||||
return
|
||||
end
|
||||
|
||||
if @opciones[:dashboard]
|
||||
puts "#{Color::YELLOW}⚠ El dashboard será implementado en Fase 3#{Color::RESET}"
|
||||
# TODO: Implementar dashboard en Fase 3
|
||||
puts "#{Color::YELLOW}⚠ El dashboard interactivo estructurado será implementado en Fase 3#{Color::RESET}"
|
||||
end
|
||||
|
||||
# Mostrar en formato legible
|
||||
puts "#{Color::CYAN}🧬 Salud del Sistema ADN#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Hebras ADN:#{Color::RESET} #{datos[:hebras_adn]} / 7"
|
||||
puts " #{Color::GREEN}Bitácoras:#{Color::RESET} #{datos[:bitacoras]}"
|
||||
puts " #{Color::GREEN}Nodos registrados:#{Color::RESET} #{datos[:nodos]}"
|
||||
puts " #{Color::GREEN}Proyectos activos:#{Color::RESET} #{datos[:proyectos]}"
|
||||
puts " #{Color::GREEN}Bitácoras MD:#{Color::RESET} #{datos[:bitacoras]}"
|
||||
puts " #{Color::GREEN}Nodos MD:#{Color::RESET} #{datos[:nodos]}"
|
||||
puts " #{Color::GREEN}Proyectos MD:#{Color::RESET} #{datos[:proyectos]}"
|
||||
|
||||
if datos[:db]
|
||||
puts ""
|
||||
db_stats = datos[:db]
|
||||
ambito_str = @opciones[:ambito] ? " (Ámbito: #{@opciones[:ambito]})" : " (Global)"
|
||||
puts " #{Color::CYAN}Estadísticas Base de Datos#{ambito_str}:#{Color::RESET}"
|
||||
puts " - Ámbitos Totales: #{db_stats[:ambitos]}" unless @opciones[:ambito]
|
||||
puts " - Nodos (DB): #{db_stats[:nodos]}"
|
||||
puts " - Eventos (DB): #{db_stats[:eventos]}"
|
||||
end
|
||||
|
||||
if datos[:errores_recientes] > 0
|
||||
puts " #{Color::RED}Errores recientes:#{Color::RESET} #{datos[:errores_recientes]}"
|
||||
@@ -96,6 +110,19 @@ module ADN
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Para más detalles: ./adn/tools/run salud --json#{Color::RESET}"
|
||||
|
||||
if @opciones[:mejoras] && datos[:sugerencias_mejora] && !datos[:sugerencias_mejora].empty?
|
||||
puts
|
||||
puts "#{Color::CYAN}💡 Sugerencias de Mejora Continua#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
datos[:sugerencias_mejora].each do |sug|
|
||||
prioridad_emoji = { alta: '🔴', media: '🟡', baja: '🟢' }[sug[:prioridad]] || '⚪'
|
||||
puts " #{prioridad_emoji} #{sug[:tipo].to_s.tr('_', ' ')}"
|
||||
puts " #{sug[:descripcion]}"
|
||||
puts " → #{sug[:accion_sugerida]}"
|
||||
puts
|
||||
end
|
||||
end
|
||||
|
||||
@logger.exito("Reporte de salud generado")
|
||||
end
|
||||
|
||||
@@ -120,6 +147,30 @@ module ADN
|
||||
Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def obtener_estadisticas_db(ambito_nombre = nil)
|
||||
require_relative '../db/core/bitacora_db'
|
||||
stats = { ambitos: 0, nodos: 0, eventos: 0 }
|
||||
begin
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
ambito_id = nil
|
||||
if ambito_nombre
|
||||
ambito = db.get_ambito(ambito_nombre)
|
||||
return nil unless ambito
|
||||
ambito_id = ambito['id']
|
||||
else
|
||||
stats[:ambitos] = db.list_ambitos.length
|
||||
end
|
||||
|
||||
stats[:nodos] = db.list_nodos(ambito_id: ambito_id, limit: 1000).length
|
||||
stats[:eventos] = db.list_entradas(ambito_id: ambito_id, limit: 10000).length
|
||||
end
|
||||
rescue StandardError => e
|
||||
@logger.error("Error obteniendo stats DB: #{e.message}")
|
||||
return nil
|
||||
end
|
||||
stats
|
||||
end
|
||||
|
||||
def contar_proyectos
|
||||
Dir.glob(File.join(ADN::PROYECTOS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
@@ -11,9 +11,9 @@ module ADN
|
||||
PROJECT_ROOT = File.expand_path('../../..', __dir__)
|
||||
|
||||
NODOS_DIR = File.join(PROJECT_ROOT, 'nodos')
|
||||
PROYECTOS_DIR = File.join(PROJECT_ROOT, 'docs', 'proyectos')
|
||||
PROYECTOS_DIR = File.join(PROJECT_ROOT, 'docs', 'proy')
|
||||
BITACORAS_DIR = File.join(PROJECT_ROOT, 'docs', 'bitacoras')
|
||||
LOGS_DIR = File.join(BITACORAS_DIR, '_hist')
|
||||
LOGS_DIR = File.join(PROJECT_ROOT, 'logs', 'adn')
|
||||
|
||||
TOOLS_DIR = File.join(PROJECT_ROOT, 'adn', 'tools')
|
||||
SEGURIDAD_DIR = File.join(TOOLS_DIR, 'seguridad')
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 001_create_ambitos.sql
|
||||
-- Creación de tabla ambitos y modificación de nodos y entradas
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Crear tabla ambitos
|
||||
CREATE TABLE IF NOT EXISTS bitacoras.ambitos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nombre VARCHAR(50) NOT NULL UNIQUE,
|
||||
descripcion TEXT,
|
||||
parent_id INTEGER REFERENCES bitacoras.ambitos(id) ON DELETE SET NULL,
|
||||
activo BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para ambitos
|
||||
CREATE INDEX IF NOT EXISTS idx_ambitos_nombre ON bitacoras.ambitos(nombre);
|
||||
CREATE INDEX IF NOT EXISTS idx_ambitos_parent ON bitacoras.ambitos(parent_id);
|
||||
|
||||
-- Modificar tabla nodos: añadir ambito_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'bitacoras' AND table_name = 'nodos' AND column_name = 'ambito_id'
|
||||
) THEN
|
||||
ALTER TABLE bitacoras.nodos
|
||||
ADD COLUMN ambito_id INTEGER REFERENCES bitacoras.ambitos(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodos_ambito ON bitacoras.nodos(ambito_id);
|
||||
END IF;
|
||||
|
||||
-- Modificar tabla entradas: añadir ambito_id
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'bitacoras' AND table_name = 'entradas' AND column_name = 'ambito_id'
|
||||
) THEN
|
||||
ALTER TABLE bitacoras.entradas
|
||||
ADD COLUMN ambito_id INTEGER REFERENCES bitacoras.ambitos(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entradas_ambito ON bitacoras.entradas(ambito_id);
|
||||
END IF;
|
||||
|
||||
-- Crear vista materializada o tabla resumen_ambitos
|
||||
CREATE TABLE IF NOT EXISTS bitacoras.resumen_ambitos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ambito_id INTEGER REFERENCES bitacoras.ambitos(id),
|
||||
fecha DATE,
|
||||
total_entradas INTEGER DEFAULT 0,
|
||||
tiempo_total INTERVAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(ambito_id, fecha)
|
||||
);
|
||||
|
||||
END $$;
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Migration: Create table for storing metrics for the continuous improvement sensor
|
||||
#
|
||||
# Usage:
|
||||
# ruby adn/tools/db/migrations/001_create_mejoras_metricas_table.rb
|
||||
#
|
||||
# This migration is designed to be run once to set up the sensor metrics table.
|
||||
|
||||
require_relative '../core/bitacora_db'
|
||||
|
||||
puts "🔧 Running migration: Create mejoras_metricas table"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# Check if table already exists
|
||||
table_exists = db.execute(<<~SQL)
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'bitacoras'
|
||||
AND table_name = 'mejoras_metricas'
|
||||
)
|
||||
SQL
|
||||
|
||||
if table_exists.first['exists']
|
||||
puts "✅ Table bitacoras.mejoras_metricas already exists. Skipping."
|
||||
else
|
||||
puts "📝 Creating table bitacoras.mejoras_metricas..."
|
||||
|
||||
create_table_sql = <<~SQL
|
||||
CREATE TABLE bitacoras.mejoras_metricas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
subcomando VARCHAR(100) NOT NULL,
|
||||
args_hash VARCHAR(64),
|
||||
nodo VARCHAR(100),
|
||||
estado_salida VARCHAR(10),
|
||||
duracion_ms INTEGER,
|
||||
es_ia BOOLEAN DEFAULT FALSE,
|
||||
contexto JSONB
|
||||
)
|
||||
SQL
|
||||
|
||||
db.execute(create_table_sql)
|
||||
puts "✅ Table bitacoras.mejoras_metricas created successfully."
|
||||
|
||||
# Create indexes for common queries
|
||||
indexes = [
|
||||
"CREATE INDEX idx_mejoras_metricas_timestamp ON bitacoras.mejoras_metricas (timestamp)",
|
||||
"CREATE INDEX idx_mejoras_metricas_subcomando ON bitacoras.mejoras_metricas (subcomando)",
|
||||
"CREATE INDEX idx_mejoras_metricas_nodo ON bitacoras.mejoras_metricas (nodo)",
|
||||
"CREATE INDEX idx_mejoras_metricas_estado ON bitacoras.mejoras_metricas (estado_salida)",
|
||||
"CREATE INDEX idx_mejoras_metricas_es_ia ON bitacoras.mejoras_metricas (es_ia)"
|
||||
]
|
||||
|
||||
indexes.each do |index_sql|
|
||||
db.execute(index_sql)
|
||||
puts "📈 Created index: #{index_sql.split(' ')[2]}"
|
||||
end
|
||||
|
||||
puts "🎉 Migration completed successfully."
|
||||
end
|
||||
end
|
||||
|
||||
puts "🏁 Migration finished."
|
||||
@@ -0,0 +1,55 @@
|
||||
-- 002_migrate_nodos_ambitos.sql
|
||||
-- Seed de ámbitos iniciales y migración de nodos
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
id_diiaa INTEGER;
|
||||
id_dasuten INTEGER;
|
||||
id_bitacoras INTEGER;
|
||||
BEGIN
|
||||
-- 1. Insertar ámbitos principales
|
||||
INSERT INTO bitacoras.ambitos (nombre, descripcion, parent_id)
|
||||
VALUES ('dtic-DIIAA', 'Ámbito raíz: Infraestructura y servicios propios', NULL)
|
||||
ON CONFLICT (nombre) DO UPDATE SET descripcion = EXCLUDED.descripcion
|
||||
RETURNING id INTO id_diiaa;
|
||||
|
||||
INSERT INTO bitacoras.ambitos (nombre, descripcion, parent_id)
|
||||
VALUES ('dtic-DASUTEN', 'Departamento de Servicios UTN (gestionado)', id_diiaa)
|
||||
ON CONFLICT (nombre) DO UPDATE SET descripcion = EXCLUDED.descripcion
|
||||
RETURNING id INTO id_dasuten;
|
||||
|
||||
INSERT INTO bitacoras.ambitos (nombre, descripcion, parent_id)
|
||||
VALUES ('dtic-BITACORAS', 'Sistema de registro ADN', id_diiaa)
|
||||
ON CONFLICT (nombre) DO UPDATE SET descripcion = EXCLUDED.descripcion
|
||||
RETURNING id INTO id_bitacoras;
|
||||
|
||||
-- 2. Migrar nodos a dtic-DASUTEN
|
||||
UPDATE bitacoras.nodos
|
||||
SET ambito_id = id_dasuten
|
||||
WHERE nombre IN ('srv-dasu', 'dasu-srvv-dc', 'sql-dasuten', 'dc-dasuten', 'pc-dasu0')
|
||||
OR nombre LIKE 'pcv-dasu%';
|
||||
|
||||
-- 3. Migrar nodos a dtic-BITACORAS
|
||||
UPDATE bitacoras.nodos
|
||||
SET ambito_id = id_bitacoras
|
||||
WHERE nombre = 'dtic-BITACORAS' OR nombre = 'dtic-DIIAA';
|
||||
|
||||
-- 4. Migrar nodos restantes a dtic-DIIAA (Infraestructura y Servicios)
|
||||
UPDATE bitacoras.nodos
|
||||
SET ambito_id = id_diiaa
|
||||
WHERE ambito_id IS NULL AND (
|
||||
nombre LIKE 'srv-%'
|
||||
OR nombre LIKE 'srvv-%'
|
||||
OR nombre = 'pc-frlr'
|
||||
OR nombre LIKE 'pc-%'
|
||||
);
|
||||
|
||||
-- Nota: Los nodos que no apliquen a ninguna regla quedarán con ambito_id NULL temporalmente
|
||||
|
||||
-- 5. Propagar ambito_id a las entradas existentes
|
||||
UPDATE bitacoras.entradas e
|
||||
SET ambito_id = n.ambito_id
|
||||
FROM bitacoras.nodos n
|
||||
WHERE e.nodo_id = n.id AND e.ambito_id IS NULL;
|
||||
|
||||
END $$;
|
||||
@@ -72,6 +72,7 @@ require_relative 'cli/generar'
|
||||
require_relative 'cli/salud'
|
||||
require_relative 'cli/ayuda'
|
||||
require_relative 'cli/nodos'
|
||||
require_relative 'cli/ambitos'
|
||||
require_relative 'cli/msp'
|
||||
require_relative 'cli/wzombi'
|
||||
require_relative 'proy/proy'
|
||||
@@ -106,6 +107,8 @@ class ADNCLI
|
||||
SubcomandoGenerar.new(args, @logger).ejecutar
|
||||
when 'nodos'
|
||||
ADN::SubcomandoNodos.new(args, @logger).ejecutar
|
||||
when 'ambitos'
|
||||
ADN::SubcomandoAmbitos.new(args, @logger).ejecutar
|
||||
when 'conocimiento'
|
||||
ADN::SubcomandoConocimiento.new(args, @logger).ejecutar
|
||||
when 'db'
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ADN
|
||||
module PlantillasEventos
|
||||
def self.existe?(tipo)
|
||||
plantillas.key?(tipo)
|
||||
end
|
||||
|
||||
def self.obtener(tipo)
|
||||
plantillas[tipo]
|
||||
end
|
||||
|
||||
def self.lista_tipos
|
||||
plantillas.keys
|
||||
end
|
||||
|
||||
def self.lista_con_iconos
|
||||
{
|
||||
"implementacion" => "🔧",
|
||||
"investigacion" => "🔍",
|
||||
"resolucion" => "🛠️",
|
||||
"documentacion" => "📝",
|
||||
"reunion" => "👥",
|
||||
"test" => "✅",
|
||||
"debug" => "🐛",
|
||||
"planificacion" => "📋",
|
||||
"migracion" => "🔄",
|
||||
"renombramiento" => "✏️",
|
||||
"backup" => "🛡️"
|
||||
}
|
||||
end
|
||||
|
||||
def self.plantillas
|
||||
{
|
||||
"implementacion" => template_implementacion,
|
||||
"investigacion" => template_investigacion,
|
||||
"resolucion" => template_resolucion,
|
||||
"documentacion" => template_documentacion,
|
||||
"reunion" => template_reunion,
|
||||
"test" => template_test,
|
||||
"debug" => template_debug,
|
||||
"planificacion" => template_planificacion,
|
||||
"migracion" => template_migracion,
|
||||
"renombramiento" => template_renombramiento,
|
||||
"backup" => template_backup
|
||||
}
|
||||
end
|
||||
|
||||
def self.template_implementacion
|
||||
<<~MD
|
||||
🔧 **Implementación**
|
||||
|
||||
- **Contexto**:
|
||||
- **Acción**:
|
||||
- **Resultado**:
|
||||
|
||||
```bash
|
||||
# código
|
||||
```
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_investigacion
|
||||
<<~MD
|
||||
🔍 **Investigación**
|
||||
|
||||
- **Problema**:
|
||||
- **Hallazgos**:
|
||||
- **Conclusión**:
|
||||
- **Refs**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_resolucion
|
||||
<<~MD
|
||||
🛠️ **Resolución**
|
||||
|
||||
- **Síntoma**:
|
||||
- **Causa**:
|
||||
- **Solución**:
|
||||
- **Verificación**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_documentacion
|
||||
<<~MD
|
||||
📝 **Documentación**
|
||||
|
||||
- **Qué**:
|
||||
- **Ubicación**:
|
||||
- **Cambios**:
|
||||
- **Impacto**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_reunion
|
||||
<<~MD
|
||||
👥 **Reunión**
|
||||
|
||||
- **Participantes**:
|
||||
- **Temas**:
|
||||
- **Acuerdos**:
|
||||
- **Acciones**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_test
|
||||
<<~MD
|
||||
✅ **Test**
|
||||
|
||||
- **Qué se probó**:
|
||||
- **Resultado**:
|
||||
- **Pasos**:
|
||||
- **Conclusión**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_debug
|
||||
<<~MD
|
||||
🐛 **Debug**
|
||||
|
||||
- **Error**:
|
||||
- **Diagnóstico**:
|
||||
- **Solución**:
|
||||
- **Verificación**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_planificacion
|
||||
<<~MD
|
||||
📋 **Planificación**
|
||||
|
||||
- **Objetivo**:
|
||||
- **Fases**:
|
||||
- **Recursos**:
|
||||
- **Próximo**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_migracion
|
||||
<<~MD
|
||||
🔄 **Migración**
|
||||
|
||||
- **Origen**:
|
||||
- **Destino**:
|
||||
- **Componentes**:
|
||||
- **Precauciones**:
|
||||
- **Rollback**:
|
||||
- **Verificación**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_renombramiento
|
||||
<<~MD
|
||||
✏️ **Renombramiento**
|
||||
|
||||
- **Anterior**:
|
||||
- **Nuevo**:
|
||||
- **Tipo**: Nodo/Archivo/Host
|
||||
- **Ubicación**:
|
||||
- **Referencias actualizadas**:
|
||||
- **Verificación**:
|
||||
MD
|
||||
end
|
||||
|
||||
def self.template_backup
|
||||
<<~MD
|
||||
🛡️ **Backup**
|
||||
|
||||
- **Origen**:
|
||||
- **Tipo**: VZDump/Export/Dump
|
||||
- **Destino**:
|
||||
- **Tamaño**:
|
||||
- **Verificación**:
|
||||
- **Restauración**:
|
||||
MD
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1 +1 @@
|
||||
{"id":8,"cmd":"Powershell -Command \"cat C:\\wz_rename.txt\""}
|
||||
{"id":40,"cmd":"Write-Host \"=== Configurando SQL Server ===\";\n# 1. Habilitar autenticación mixta via registro\nSet-ItemProperty -Path \"HKLM:\\SOFTWARE\\Microsoft\\Microsoft SQL Server\\MSSQL15.MSSQLSERVER\\MSSQLServer\" -Name \"LoginMode\" -Value 2;\nWrite-Host \"Auth mixta habilitada (LoginMode=2)\";\n\n# 2. Setear SA password via sqlcmd\n$sqlcmd = \"C:\\Program Files\\Microsoft SQL Server\\Client SDK\\ODBC\\170\\Tools\\Binn\\SQLCMD.EXE\";\nif (-not (Test-Path $sqlcmd)) { $sqlcmd = (Get-ChildItem \"C:\\Program Files\\Microsoft SQL Server\" -Recurse -Filter \"sqlcmd.exe\" | Select-Object -First 1).FullName };\nWrite-Host \"sqlcmd: $sqlcmd\";\n& $sqlcmd -S localhost -E -Q \"ALTER LOGIN [sa] WITH PASSWORD = N'SaP@ss2026DASU!', CHECK_POLICY = OFF; ALTER LOGIN [sa] ENABLE; SELECT name, is_disabled FROM sys.server_principals WHERE name = 'sa'\";\n\n# 3. Reiniciar SQL para aplicar auth mixta\nRestart-Service MSSQLSERVER -Force;\nStart-Sleep -Seconds 5;\n\n# 4. Firewall\nnetsh advfirewall firewall add rule name=\"SQL Server 1433\" dir=in action=allow protocol=TCP localport=1433;\nWrite-Host \"=== Firewall OK ===\";\n\n# 5. Verificar\nGet-Service MSSQLSERVER | Format-List Status;\n& $sqlcmd -S localhost -U sa -P \"SaP@ss2026DASU!\" -Q \"SELECT @@VERSION\""}
|
||||
Reference in New Issue
Block a user