Se introduce una refactorización masiva y se añaden nuevas funcionalidades críticas para la gestión del inventario, incluyendo un panel de administración para la limpieza de datos y un sistema completo para la gestión manual de equipos.
### Nuevas Funcionalidades
*   **Panel de Administración:** Se crea una nueva vista de "Administración" para la gestión de datos maestros. Permite unificar valores inconsistentes (ej: "W10" -> "Windows 10 Pro") y eliminar registros maestros no utilizados (ej: Módulos de RAM) para mantener la base de datos limpia.
*   **Gestión de Sectores (CRUD):** Se añade una vista dedicada para crear, editar y eliminar sectores de la organización.
*   **Diferenciación Manual vs. Automático:** Se introduce una columna `origen` en la base de datos para distinguir entre los datos recopilados automáticamente por el script y los introducidos manualmente por el usuario. La UI ahora refleja visualmente este origen.
*   **CRUD de Equipos Manuales:** Se implementa la capacidad de crear, editar y eliminar equipos de origen "manual" a través de la interfaz de usuario. Se protege la eliminación de equipos automáticos.
*   **Gestión de Componentes Manuales:** Se permite añadir y eliminar componentes (Discos, RAM, Usuarios) a los equipos de origen "manual".
### Mejoras de UI/UX
*   **Refactorización de Estilos:** Se migran todos los estilos en línea del componente `SimpleTable` a un archivo CSS Module (`SimpleTable.module.css`), mejorando la mantenibilidad y el rendimiento.
*   **Notificaciones de Usuario:** Se integra `react-hot-toast` para proporcionar feedback visual inmediato (carga, éxito, error) en todas las operaciones asíncronas, reemplazando los `alert`.
*   **Componentización:** Se extraen todos los modales (`ModalDetallesEquipo`, `ModalAnadirEquipo`, etc.) a sus propios componentes, limpiando y simplificando drásticamente el componente `SimpleTable`.
*   **Paginación en Tabla Principal:** Se implementa paginación completa en la tabla de equipos, con controles para navegar, ir a una página específica y cambiar el número de items por página. Se añade un indicador de carga inicial.
*   **Navegación Mejorada:** Se reemplaza la navegación por botones con un componente `Navbar` estilizado y dedicado, mejorando la estructura visual y de código.
*   **Autocompletado de Datos:** Se introduce un componente `AutocompleteInput` reutilizable para guiar al usuario a usar datos consistentes al rellenar campos como OS, CPU y Motherboard. Se implementa búsqueda dinámica para la asociación de usuarios.
*   **Validación de MAC Address:** Se añade validación de formato en tiempo real y auto-formateo para el campo de MAC Address, reduciendo errores humanos.
*   **Consistencia de Iconos:** Se unifica el icono de eliminación a (🗑️) en toda la aplicación para una experiencia de usuario más coherente.
### Mejoras en el Backend / API
*   **Seguridad de Credenciales:** Las credenciales SSH para la función Wake On Lan se mueven del código fuente a `appsettings.json`.
*   **Nuevo `AdminController`:** Se crea un controlador dedicado para las tareas administrativas, con endpoints para obtener valores únicos de componentes y para ejecutar la lógica de unificación y eliminación.
*   **Endpoints de Gestión Manual:** Se añaden rutas específicas (`/manual/...` y `/asociacion/...`) para la manipulación de datos de origen manual, separando la lógica de la gestión automática.
*   **Protección de Datos Automáticos:** Los endpoints `DELETE` y `PUT` ahora validan el campo `origen` para prevenir la modificación o eliminación no deseada de datos generados automáticamente.
*   **Correcciones y Refinamiento:** Se soluciona el mapeo incorrecto de fechas (`created_at`, `updated_at`), se corrigen errores de compilación y se refinan las consultas SQL para incluir los nuevos campos.
		
	
		
			
				
	
	
		
			890 lines
		
	
	
		
			38 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			890 lines
		
	
	
		
			38 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using Dapper;
 | |
| using Inventario.API.Data;
 | |
| using Inventario.API.DTOs;
 | |
| using Inventario.API.Helpers;
 | |
| using Inventario.API.Models;
 | |
| using Microsoft.AspNetCore.Mvc;
 | |
| using System.Data;
 | |
| using System.Net.NetworkInformation;
 | |
| using Microsoft.Data.SqlClient;
 | |
| using Renci.SshNet;
 | |
| using System.Text.RegularExpressions;
 | |
| 
 | |
| namespace Inventario.API.Controllers
 | |
| {
 | |
|   [ApiController]
 | |
|   [Route("api/[controller]")]
 | |
|   public class EquiposController : ControllerBase
 | |
|   {
 | |
|     private readonly DapperContext _context;
 | |
|     private readonly IConfiguration _configuration; // 1. Añadimos el campo para la configuración
 | |
| 
 | |
|     // 2. Modificamos el constructor para inyectar IConfiguration
 | |
|     public EquiposController(DapperContext context, IConfiguration configuration)
 | |
|     {
 | |
|       _context = context;
 | |
|       _configuration = configuration; // Asignamos la configuración inyectada
 | |
|     }
 | |
| 
 | |
|     // --- MÉTODOS CRUD BÁSICOS ---
 | |
|     // GET /api/equipos
 | |
|     [HttpGet]
 | |
|     public async Task<IActionResult> Consultar()
 | |
|     {
 | |
|       var query = @"
 | |
|                 SELECT 
 | |
|                     e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen,
 | |
|                     s.Id as Id, s.Nombre,
 | |
|                     u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
 | |
|                     d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
 | |
|                     mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot, emr.Origen as Origen, emr.Id as EquipoMemoriaRamId
 | |
|                 FROM dbo.equipos e
 | |
|                 LEFT JOIN dbo.sectores s ON e.sector_id = s.id
 | |
|                 LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
 | |
|                 LEFT JOIN dbo.usuarios u ON ue.usuario_id = u.id
 | |
|                 LEFT JOIN dbo.equipos_discos ed ON e.id = ed.equipo_id
 | |
|                 LEFT JOIN dbo.discos d ON ed.disco_id = d.id
 | |
|                 LEFT JOIN dbo.equipos_memorias_ram emr ON e.id = emr.equipo_id
 | |
|                 LEFT JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id;";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipoDict = new Dictionary<int, Equipo>();
 | |
| 
 | |
|         // CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper
 | |
|         await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
 | |
|             query, (equipo, sector, usuario, disco, memoria) =>
 | |
|             {
 | |
|               if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual))
 | |
|               {
 | |
|                 equipoActual = equipo;
 | |
|                 equipoActual.Sector = sector;
 | |
|                 equipoDict.Add(equipoActual.Id, equipoActual);
 | |
|               }
 | |
|               // CAMBIO: Se ajusta la lógica para evitar duplicados en los nuevos tipos detallados
 | |
|               if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
 | |
|                 equipoActual.Usuarios.Add(usuario);
 | |
|               if (disco != null && !equipoActual.Discos.Any(d => d.Id == disco.Id))
 | |
|                 equipoActual.Discos.Add(disco);
 | |
|               if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.Id == memoria.Id && m.Slot == memoria.Slot))
 | |
|                 equipoActual.MemoriasRam.Add(memoria);
 | |
| 
 | |
|               return equipoActual;
 | |
|             },
 | |
|             splitOn: "Id,Id,Id,Id"
 | |
|         );
 | |
|         return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname));
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // --- GET /api/equipos/{hostname} ---
 | |
|     [HttpGet("{hostname}")]
 | |
|     public async Task<IActionResult> ConsultarDetalle(string hostname)
 | |
|     {
 | |
|       var query = @"SELECT 
 | |
|                     e.*, 
 | |
|                     s.Id as SectorId, s.Nombre as SectorNombre,
 | |
|                     u.Id as UsuarioId, u.Username, u.Password, ue.Origen as Origen
 | |
|                 FROM dbo.equipos e
 | |
|                 LEFT JOIN dbo.sectores s ON e.sector_id = s.id
 | |
|                 LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
 | |
|                 LEFT JOIN dbo.usuarios u ON ue.usuario_id = u.id
 | |
|                 WHERE e.Hostname = @Hostname;";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipoDict = new Dictionary<int, Equipo>();
 | |
| 
 | |
|         var equipo = (await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, Equipo>(
 | |
|             query, (e, sector, usuario) =>
 | |
|             {
 | |
|               if (!equipoDict.TryGetValue(e.Id, out var equipoActual))
 | |
|               {
 | |
|                 equipoActual = e;
 | |
|                 equipoActual.Sector = sector;
 | |
|                 equipoDict.Add(equipoActual.Id, equipoActual);
 | |
|               }
 | |
|               if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
 | |
|                 equipoActual.Usuarios.Add(usuario);
 | |
| 
 | |
|               return equipoActual;
 | |
|             },
 | |
|             new { Hostname = hostname },
 | |
|             splitOn: "SectorId,UsuarioId"
 | |
|         )).FirstOrDefault();
 | |
| 
 | |
|         if (equipo == null) return NotFound("Equipo no encontrado.");
 | |
| 
 | |
|         var discosQuery = "SELECT d.*, ed.Origen, ed.Id as EquipoDiscoId FROM dbo.discos d JOIN dbo.equipos_discos ed ON d.Id = ed.disco_id WHERE ed.equipo_id = @Id";
 | |
|         equipo.Discos = (await connection.QueryAsync<DiscoDetalle>(discosQuery, new { equipo.Id })).ToList();
 | |
| 
 | |
|         var ramQuery = "SELECT mr.*, emr.Slot, emr.Origen, emr.Id as EquipoMemoriaRamId FROM dbo.memorias_ram mr JOIN dbo.equipos_memorias_ram emr ON mr.Id = emr.memoria_ram_id WHERE emr.equipo_id = @Id";
 | |
|         equipo.MemoriasRam = (await connection.QueryAsync<MemoriaRamEquipoDetalle>(ramQuery, new { equipo.Id })).ToList();
 | |
| 
 | |
|         return Ok(equipo);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // --- POST /api/equipos/{hostname} ---
 | |
|     [HttpPost("{hostname}")]
 | |
|     public async Task<IActionResult> Ingresar(string hostname, [FromBody] Equipo equipoData)
 | |
|     {
 | |
|       var findQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipoExistente = await connection.QuerySingleOrDefaultAsync<Equipo>(findQuery, new { Hostname = hostname });
 | |
| 
 | |
|         if (equipoExistente == null)
 | |
|         {
 | |
|           // Crear
 | |
|           var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture, Origen) 
 | |
|                                       VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture, 'automatica');
 | |
|                                       SELECT CAST(SCOPE_IDENTITY() as int);";
 | |
|           var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoData);
 | |
|           equipoData.Id = nuevoId;
 | |
|           return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = equipoData.Hostname }, equipoData);
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|           // Actualizar y registrar historial
 | |
|           var cambios = new Dictionary<string, (string anterior, string nuevo)>();
 | |
| 
 | |
|           // Comparamos campos para registrar en historial
 | |
|           if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
 | |
|           if (equipoData.Mac != equipoExistente.Mac) cambios["mac"] = (equipoExistente.Mac ?? "", equipoData.Mac ?? "");
 | |
| 
 | |
|           var updateQuery = @"UPDATE dbo.equipos SET Ip = @Ip, Mac = @Mac, Motherboard = @Motherboard, 
 | |
|                                       Cpu = @Cpu, Ram_installed = @Ram_installed, Ram_slots = @Ram_slots, Os = @Os, Architecture = @Architecture
 | |
|                                       WHERE Hostname = @Hostname;";
 | |
|           await connection.ExecuteAsync(updateQuery, equipoData);
 | |
| 
 | |
|           if (cambios.Count > 0)
 | |
|           {
 | |
|             await HistorialHelper.RegistrarCambios(_context, equipoExistente.Id, cambios);
 | |
|           }
 | |
| 
 | |
|           return Ok(equipoData);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // --- PUT /api/equipos/{id} ---
 | |
|     [HttpPut("{id}")]
 | |
|     public async Task<IActionResult> Actualizar(int id, [FromBody] Equipo equipoData)
 | |
|     {
 | |
|       var updateQuery = @"UPDATE dbo.equipos SET Hostname = @Hostname, Ip = @Ip, Mac = @Mac, Motherboard = @Motherboard, 
 | |
|                               Cpu = @Cpu, Ram_installed = @Ram_installed, Ram_slots = @Ram_slots, Os = @Os, Architecture = @Architecture
 | |
|                               WHERE Id = @Id;";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         // Asignamos el ID del parámetro de la ruta al objeto que recibimos.
 | |
|         equipoData.Id = id;
 | |
|         // Ahora pasamos el objeto completo a Dapper.
 | |
|         var filasAfectadas = await connection.ExecuteAsync(updateQuery, equipoData);
 | |
|         if (filasAfectadas == 0) return NotFound("Equipo no encontrado.");
 | |
|         return NoContent();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // --- DELETE /api/equipos/{id} ---
 | |
|     [HttpDelete("{id}")]
 | |
|     public async Task<IActionResult> Borrar(int id)
 | |
|     {
 | |
|       var query = "DELETE FROM dbo.equipos WHERE Id = @Id AND Origen = 'manual';";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
 | |
|         if (filasAfectadas == 0)
 | |
|         {
 | |
|           // Puede que no se haya borrado porque no existe o porque es automático.
 | |
|           // Damos un mensaje de error genérico pero informativo.
 | |
|           return NotFound("Equipo no encontrado o no se puede eliminar porque fue generado automáticamente.");
 | |
|         }
 | |
|         return NoContent();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // --- GET /api/equipos/{hostname}/historial ---
 | |
|     [HttpGet("{hostname}/historial")]
 | |
|     public async Task<IActionResult> ConsultarHistorial(string hostname)
 | |
|     {
 | |
|       var query = @"SELECT h.* FROM dbo.historial_equipos h
 | |
|                           JOIN dbo.equipos e ON h.equipo_id = e.id
 | |
|                           WHERE e.Hostname = @Hostname
 | |
|                           ORDER BY h.fecha_cambio DESC;";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname", new { Hostname = hostname });
 | |
|         if (equipo == null) return NotFound("Equipo no encontrado.");
 | |
| 
 | |
|         var historial = await connection.QueryAsync<HistorialEquipo>(query, new { Hostname = hostname });
 | |
|         return Ok(new { equipo = hostname, historial });
 | |
|       }
 | |
|     }
 | |
|     // --- MÉTODOS DE ASOCIACIÓN Y COMANDOS ---
 | |
| 
 | |
|     [HttpPatch("{id_equipo}/sector/{id_sector}")]
 | |
|     public async Task<IActionResult> AsociarSector(int id_equipo, int id_sector)
 | |
|     {
 | |
|       var query = "UPDATE dbo.equipos SET sector_id = @IdSector WHERE Id = @IdEquipo;";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var filasAfectadas = await connection.ExecuteAsync(query, new { IdSector = id_sector, IdEquipo = id_equipo });
 | |
|         if (filasAfectadas == 0) return NotFound("Equipo o sector no encontrado.");
 | |
|         return Ok(new { success = true }); // Devolvemos una respuesta simple de éxito
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("{hostname}/asociarusuario")]
 | |
|     public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
 | |
|     {
 | |
|       var query = @"
 | |
|                 INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
 | |
|                 SELECT e.id, u.id, 'automatica'
 | |
|                 FROM dbo.equipos e, dbo.usuarios u
 | |
|                 WHERE e.Hostname = @Hostname AND u.Username = @Username;";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         try
 | |
|         {
 | |
|           var filasAfectadas = await connection.ExecuteAsync(query, new { Hostname = hostname, dto.Username });
 | |
|           if (filasAfectadas == 0) return NotFound("Equipo o usuario no encontrado.");
 | |
|           return Ok(new { success = true });
 | |
|         }
 | |
|         catch (SqlException ex) when (ex.Number == 2627) // Error de clave primaria duplicada
 | |
|         {
 | |
|           return Conflict("El usuario ya está asociado a este equipo.");
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpDelete("{hostname}/usuarios/{username}")]
 | |
|     public async Task<IActionResult> DesasociarUsuario(string hostname, string username)
 | |
|     {
 | |
|       var query = @"
 | |
|                 DELETE FROM dbo.usuarios_equipos 
 | |
|                 WHERE equipo_id = (SELECT id FROM dbo.equipos WHERE Hostname = @Hostname)
 | |
|                 AND usuario_id = (SELECT id FROM dbo.usuarios WHERE Username = @Username);";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var filasAfectadas = await connection.ExecuteAsync(query, new { Hostname = hostname, Username = username });
 | |
|         if (filasAfectadas == 0) return NotFound("Asociación no encontrada.");
 | |
|         return Ok(new { success = true });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("{hostname}/asociardiscos")]
 | |
|     public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
 | |
|     {
 | |
|       // 1. OBTENER EL EQUIPO
 | |
|       var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
 | |
|       using var connection = _context.CreateConnection();
 | |
|       connection.Open();
 | |
|       var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
 | |
| 
 | |
|       if (equipo == null)
 | |
|       {
 | |
|         return NotFound("Equipo no encontrado.");
 | |
|       }
 | |
| 
 | |
|       // Iniciar una transacción para asegurar que todas las operaciones se completen o ninguna lo haga.
 | |
|       using var transaction = connection.BeginTransaction();
 | |
|       try
 | |
|       {
 | |
|         // 2. OBTENER ASOCIACIONES Y DISCOS ACTUALES DE LA BD
 | |
|         var discosActualesQuery = @"
 | |
|             SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
 | |
|             FROM dbo.equipos_discos ed
 | |
|             JOIN dbo.discos d ON ed.disco_id = d.id
 | |
|             WHERE ed.equipo_id = @EquipoId;";
 | |
| 
 | |
|         var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
 | |
| 
 | |
|         // 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
 | |
|         // Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
 | |
|         var discosClienteContados = discosDesdeCliente
 | |
|             .GroupBy(d => $"{d.Mediatype}_{d.Size}")
 | |
|             .ToDictionary(g => g.Key, g => g.Count());
 | |
| 
 | |
|         var discosDbContados = discosEnDb
 | |
|             .GroupBy(d => $"{d.Mediatype}_{d.Size}")
 | |
|             .ToDictionary(g => g.Key, g => g.Count());
 | |
| 
 | |
|         var cambios = new Dictionary<string, (string anterior, string nuevo)>();
 | |
| 
 | |
|         // 4. CALCULAR Y EJECUTAR ELIMINACIONES
 | |
|         var discosAEliminar = new List<int>();
 | |
|         foreach (var discoDb in discosEnDb)
 | |
|         {
 | |
|           var key = $"{discoDb.Mediatype}_{discoDb.Size}";
 | |
|           if (discosClienteContados.TryGetValue(key, out int count) && count > 0)
 | |
|           {
 | |
|             // Este disco todavía existe en el cliente, decrementamos el contador y lo saltamos.
 | |
|             discosClienteContados[key]--;
 | |
|           }
 | |
|           else
 | |
|           {
 | |
|             // Este disco ya no está en el cliente, marcamos su asociación para eliminar.
 | |
|             discosAEliminar.Add(discoDb.EquipoDiscoId);
 | |
| 
 | |
|             // Registrar para el historial
 | |
|             var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
 | |
|             var anterior = discosDbContados.GetValueOrDefault(key, 0);
 | |
|             if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
 | |
|             else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) - 1).ToString());
 | |
|           }
 | |
|         }
 | |
|         if (discosAEliminar.Any())
 | |
|         {
 | |
|           await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction);
 | |
|         }
 | |
| 
 | |
|         // 5. CALCULAR Y EJECUTAR INSERCIONES
 | |
|         foreach (var discoCliente in discosDesdeCliente)
 | |
|         {
 | |
|           var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
 | |
|           if (discosDbContados.TryGetValue(key, out int count) && count > 0)
 | |
|           {
 | |
|             // Este disco ya existía, decrementamos para no volver a añadirlo.
 | |
|             discosDbContados[key]--;
 | |
|           }
 | |
|           else
 | |
|           {
 | |
|             // Este es un disco nuevo que hay que asociar.
 | |
|             var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
 | |
|             if (disco == null) continue;
 | |
| 
 | |
|             await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
 | |
| 
 | |
|             // Registrar para el historial
 | |
|             var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
 | |
|             var anterior = discosDbContados.GetValueOrDefault(key, 0);
 | |
|             if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
 | |
|             else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) + 1).ToString());
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         // 6. REGISTRAR CAMBIOS Y CONFIRMAR TRANSACCIÓN
 | |
|         if (cambios.Count > 0)
 | |
|         {
 | |
|           // Formateamos los valores para el historial
 | |
|           var cambiosFormateados = cambios.ToDictionary(
 | |
|               kvp => kvp.Key,
 | |
|               kvp => ($"{kvp.Value.anterior} Instalados", $"{kvp.Value.nuevo} Instalados")
 | |
|           );
 | |
|           await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados);
 | |
|         }
 | |
| 
 | |
|         transaction.Commit();
 | |
| 
 | |
|         return Ok(new { message = "Discos sincronizados correctamente." });
 | |
|       }
 | |
|       catch (Exception ex)
 | |
|       {
 | |
|         transaction.Rollback();
 | |
|         // Loggear el error en el servidor
 | |
|         Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
 | |
|         return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("{hostname}/ram")]
 | |
|     public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamEquipoDetalle> memoriasDesdeCliente)
 | |
|     {
 | |
|       var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
 | |
|       using var connection = _context.CreateConnection();
 | |
|       connection.Open();
 | |
|       var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
 | |
|       if (equipo == null) return NotFound("Equipo no encontrado.");
 | |
|       using var transaction = connection.BeginTransaction();
 | |
|       try
 | |
|       {
 | |
|         var ramActualQuery = @"
 | |
|             SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad
 | |
|             FROM dbo.equipos_memorias_ram emr
 | |
|             JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
 | |
|             WHERE emr.equipo_id = @EquipoId;";
 | |
|         var ramEnDb = (await connection.QueryAsync<dynamic>(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
 | |
| 
 | |
|         Func<dynamic, string> crearHuella = ram => $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
 | |
|         var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
 | |
|         var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
 | |
| 
 | |
|         var cambios = new Dictionary<string, (string anterior, string nuevo)>();
 | |
|         Func<dynamic, string> formatRamDetails = ram =>
 | |
|         {
 | |
|           var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
 | |
|           return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
 | |
|         };
 | |
| 
 | |
|         var modulosEliminados = ramEnDb.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))).ToList();
 | |
|         foreach (var modulo in modulosEliminados)
 | |
|         {
 | |
|           var campo = $"RAM Slot {modulo.Slot}";
 | |
|           cambios[campo] = (formatRamDetails(modulo), "Vacio");
 | |
|         }
 | |
| 
 | |
|         var modulosInsertados = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
 | |
|         foreach (var modulo in modulosInsertados)
 | |
|         {
 | |
|           var campo = $"RAM Slot {modulo.Slot}";
 | |
|           var valorNuevo = formatRamDetails(modulo);
 | |
|           if (cambios.ContainsKey(campo))
 | |
|           {
 | |
|             cambios[campo] = (cambios[campo].anterior, valorNuevo);
 | |
|           }
 | |
|           else
 | |
|           {
 | |
|             cambios[campo] = ("Vacio", valorNuevo);
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         var asociacionesAEliminar = modulosEliminados.Select(ramDb => (int)ramDb.EquipoMemoriaRamId).ToList();
 | |
|         if (asociacionesAEliminar.Any())
 | |
|         {
 | |
|           await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction);
 | |
|         }
 | |
| 
 | |
|         foreach (var memInfo in modulosInsertados)
 | |
|         {
 | |
|           var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
 | |
|           var memoriaMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(findRamQuery, memInfo, transaction);
 | |
|           int memoriaMaestraId;
 | |
|           if (memoriaMaestra == null)
 | |
|           {
 | |
|             var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);";
 | |
|             memoriaMaestraId = await connection.ExecuteScalarAsync<int>(insertRamQuery, memInfo, transaction);
 | |
|           }
 | |
|           else
 | |
|           {
 | |
|             memoriaMaestraId = memoriaMaestra.Id;
 | |
|           }
 | |
|           // Crear la asociación en la tabla intermedia
 | |
|           var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @MemoriaRamId, @Slot, 'automatica');";
 | |
|           await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction);
 | |
|         }
 | |
| 
 | |
|         if (cambios.Count > 0)
 | |
|         {
 | |
|           await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambios);
 | |
|         }
 | |
| 
 | |
|         transaction.Commit();
 | |
|         return Ok(new { message = "Módulos de RAM sincronizados correctamente." });
 | |
|       }
 | |
|       catch (Exception ex)
 | |
|       {
 | |
|         transaction.Rollback();
 | |
|         Console.WriteLine($"Error al asociar RAM para {hostname}: {ex.Message}");
 | |
|         return StatusCode(500, "Ocurrió un error interno al procesar la solicitud de RAM.");
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("ping")]
 | |
|     public async Task<IActionResult> EnviarPing([FromBody] PingRequestDto request)
 | |
|     {
 | |
|       if (string.IsNullOrWhiteSpace(request.Ip))
 | |
|         return BadRequest("La dirección IP es requerida.");
 | |
| 
 | |
|       try
 | |
|       {
 | |
|         using (var ping = new Ping())
 | |
|         {
 | |
|           var reply = await ping.SendPingAsync(request.Ip, 2000);
 | |
|           bool isAlive = reply.Status == IPStatus.Success;
 | |
|           if (!isAlive)
 | |
|           {
 | |
|             reply = await ping.SendPingAsync(request.Ip, 2000);
 | |
|             isAlive = reply.Status == IPStatus.Success;
 | |
|           }
 | |
|           return Ok(new { isAlive, latency = isAlive ? reply.RoundtripTime : (long?)null });
 | |
|         }
 | |
|       }
 | |
|       catch (PingException ex)
 | |
|       {
 | |
|         Console.WriteLine($"Error de Ping para {request.Ip}: {ex.Message}");
 | |
|         return Ok(new { isAlive = false, error = "Host no alcanzable o desconocido." });
 | |
|       }
 | |
|       catch (Exception ex)
 | |
|       {
 | |
|         Console.WriteLine($"Error interno al hacer ping a {request.Ip}: {ex.Message}");
 | |
|         return StatusCode(500, "Error interno del servidor al realizar el ping.");
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("wake-on-lan")]
 | |
|     public IActionResult EnviarWol([FromBody] WolRequestDto request)
 | |
|     {
 | |
|       var mac = request.Mac;
 | |
|       var ip = request.Ip;
 | |
| 
 | |
|       if (string.IsNullOrWhiteSpace(mac) || !Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))
 | |
|       {
 | |
|         return BadRequest("Formato de dirección MAC inválido.");
 | |
|       }
 | |
|       if (string.IsNullOrWhiteSpace(ip) || !Regex.IsMatch(ip, @"^(\d{1,3}\.){3}\d{1,3}$"))
 | |
|       {
 | |
|         return BadRequest("Formato de dirección IP inválido.");
 | |
|       }
 | |
| 
 | |
|       var octetos = ip.Split('.');
 | |
|       if (octetos.Length != 4)
 | |
|       {
 | |
|         return BadRequest("Formato de dirección IP incorrecto.");
 | |
|       }
 | |
| 
 | |
|       var vlanNumber = octetos[2];
 | |
|       var interfaceName = $"vlan{vlanNumber}";
 | |
| 
 | |
|       // 3. Leemos los valores desde la configuración en lugar de hardcodearlos
 | |
|       var sshHost = _configuration.GetValue<string>("SshSettings:Host");
 | |
|       var sshPort = _configuration.GetValue<int>("SshSettings:Port");
 | |
|       var sshUser = _configuration.GetValue<string>("SshSettings:User");
 | |
|       var sshPass = _configuration.GetValue<string>("SshSettings:Password");
 | |
| 
 | |
|       if (string.IsNullOrEmpty(sshHost) || string.IsNullOrEmpty(sshUser) || string.IsNullOrEmpty(sshPass))
 | |
|       {
 | |
|         Console.WriteLine("Error: La configuración SSH no está completa en appsettings.json.");
 | |
|         return StatusCode(500, "La configuración del servidor SSH está incompleta.");
 | |
|       }
 | |
| 
 | |
|       try
 | |
|       {
 | |
|         using (var client = new SshClient(sshHost, sshPort, sshUser, sshPass))
 | |
|         {
 | |
|           client.Connect();
 | |
|           if (client.IsConnected)
 | |
|           {
 | |
|             var command = $"/usr/sbin/etherwake -b -i {interfaceName} {mac}";
 | |
|             var sshCommand = client.CreateCommand(command);
 | |
|             sshCommand.Execute();
 | |
| 
 | |
|             Console.WriteLine($"Comando WOL ejecutado: {sshCommand.CommandText}");
 | |
| 
 | |
|             client.Disconnect();
 | |
|           }
 | |
|           else
 | |
|           {
 | |
|             Console.WriteLine("Error: No se pudo conectar al servidor SSH.");
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|       catch (Exception ex)
 | |
|       {
 | |
|         Console.WriteLine($"Error al ejecutar comando WOL: {ex.Message}");
 | |
|       }
 | |
| 
 | |
|       return NoContent();
 | |
|     }
 | |
| 
 | |
|     [HttpPost("manual")]
 | |
|     public async Task<IActionResult> CrearEquipoManual([FromBody] CrearEquipoManualDto equipoDto)
 | |
|     {
 | |
|       var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;";
 | |
|       var insertQuery = @"
 | |
|             INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture) 
 | |
|             VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
 | |
|             SELECT CAST(SCOPE_IDENTITY() as int);";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var existente = await connection.QuerySingleOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
 | |
|         if (existente.HasValue)
 | |
|         {
 | |
|           return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
 | |
|         }
 | |
| 
 | |
|         var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
 | |
| 
 | |
|         // Devolvemos el objeto completo para que el frontend pueda actualizar su estado
 | |
|         var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
 | |
| 
 | |
|         if (nuevoEquipo == null)
 | |
|         {
 | |
|           return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
 | |
|         }
 | |
| 
 | |
|         return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // --- ENDPOINTS PARA BORRADO MANUAL DE ASOCIACIONES ---
 | |
| 
 | |
|     [HttpDelete("asociacion/disco/{equipoDiscoId}")]
 | |
|     public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
 | |
|     {
 | |
|       var query = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId AND Origen = 'manual';";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId });
 | |
|         if (filasAfectadas == 0)
 | |
|         {
 | |
|           return NotFound("Asociación de disco no encontrada o no se puede eliminar porque es automática.");
 | |
|         }
 | |
|         return NoContent();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
 | |
|     public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
 | |
|     {
 | |
|       var query = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId AND Origen = 'manual';";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId });
 | |
|         if (filasAfectadas == 0)
 | |
|         {
 | |
|           return NotFound("Asociación de RAM no encontrada o no se puede eliminar porque es automática.");
 | |
|         }
 | |
|         return NoContent();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
 | |
|     public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
 | |
|     {
 | |
|       var query = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoId = equipoId, UsuarioId = usuarioId });
 | |
|         if (filasAfectadas == 0)
 | |
|         {
 | |
|           return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática.");
 | |
|         }
 | |
|         return NoContent();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPut("manual/{id}")]
 | |
|     public async Task<IActionResult> ActualizarEquipoManual(int id, [FromBody] EditarEquipoManualDto equipoDto)
 | |
|     {
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         // 1. Verificar que el equipo existe y es manual
 | |
|         var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
 | |
|         if (equipoActual == null)
 | |
|         {
 | |
|           return NotFound("El equipo no existe.");
 | |
|         }
 | |
|         if (equipoActual.Origen != "manual")
 | |
|         {
 | |
|           return Forbid("No se puede modificar un equipo generado automáticamente.");
 | |
|         }
 | |
| 
 | |
|         // 2. (Opcional pero recomendado) Verificar que el nuevo hostname no exista ya en otro equipo
 | |
|         if (equipoActual.Hostname != equipoDto.Hostname)
 | |
|         {
 | |
|           var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
 | |
|           if (hostExistente.HasValue)
 | |
|           {
 | |
|             return Conflict($"El hostname '{equipoDto.Hostname}' ya está en uso por otro equipo.");
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         // 3. Construir y ejecutar la consulta de actualización
 | |
|         var updateQuery = @"UPDATE dbo.equipos SET 
 | |
|                                     Hostname = @Hostname, 
 | |
|                                     Ip = @Ip, 
 | |
|                                     Mac = @Mac,
 | |
|                                     Motherboard = @Motherboard, 
 | |
|                                     Cpu = @Cpu, 
 | |
|                                     Os = @Os, 
 | |
|                                     Sector_id = @Sector_id,
 | |
|                                     updated_at = GETDATE()
 | |
|                                 WHERE Id = @Id AND Origen = 'manual';";
 | |
| 
 | |
|         var filasAfectadas = await connection.ExecuteAsync(updateQuery, new
 | |
|         {
 | |
|           equipoDto.Hostname,
 | |
|           equipoDto.Ip,
 | |
|           mac = equipoDto.Mac,
 | |
|           equipoDto.Motherboard,
 | |
|           equipoDto.Cpu,
 | |
|           equipoDto.Os,
 | |
|           equipoDto.Sector_id,
 | |
|           Id = id
 | |
|         });
 | |
| 
 | |
|         if (filasAfectadas == 0)
 | |
|         {
 | |
|           // Esto no debería pasar si las primeras verificaciones pasaron, pero es una salvaguarda
 | |
|           return StatusCode(500, "No se pudo actualizar el equipo.");
 | |
|         }
 | |
| 
 | |
|         return NoContent(); // Éxito en la actualización
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("manual/{equipoId}/disco")]
 | |
|     public async Task<IActionResult> AsociarDiscoManual(int equipoId, [FromBody] AsociarDiscoManualDto dto)
 | |
|     {
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
 | |
|         if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
 | |
| 
 | |
|         // Buscar o crear el disco maestro
 | |
|         var discoMaestro = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
 | |
|         int discoId;
 | |
|         if (discoMaestro == null)
 | |
|         {
 | |
|           discoId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|           discoId = discoMaestro.Id;
 | |
|         }
 | |
| 
 | |
|         // Crear la asociación manual
 | |
|         var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
 | |
|         var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId });
 | |
| 
 | |
|         return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("manual/{equipoId}/ram")]
 | |
|     public async Task<IActionResult> AsociarRamManual(int equipoId, [FromBody] AsociarRamManualDto dto)
 | |
|     {
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
 | |
|         if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
 | |
| 
 | |
|         // Lógica similar a la de discos para buscar/crear el módulo maestro
 | |
|         int ramId;
 | |
|         var ramMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>("SELECT * FROM dbo.memorias_ram WHERE Tamano = @Tamano AND Fabricante = @Fabricante AND Velocidad = @Velocidad", dto);
 | |
|         if (ramMaestra == null)
 | |
|         {
 | |
|           ramId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.memorias_ram (Tamano, Fabricante, Velocidad) VALUES (@Tamano, @Fabricante, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|           ramId = ramMaestra.Id;
 | |
|         }
 | |
| 
 | |
|         // Crear la asociación manual
 | |
|         var asociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @RamId, @Slot, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
 | |
|         var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot });
 | |
| 
 | |
|         return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("manual/{equipoId}/usuario")]
 | |
|     public async Task<IActionResult> AsociarUsuarioManual(int equipoId, [FromBody] AsociarUsuarioManualDto dto)
 | |
|     {
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
 | |
|         if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
 | |
| 
 | |
|         // Buscar o crear el usuario maestro
 | |
|         int usuarioId;
 | |
|         var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
 | |
|         if (usuario == null)
 | |
|         {
 | |
|           usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|           usuarioId = usuario.Id;
 | |
|         }
 | |
| 
 | |
|         // Crear la asociación manual
 | |
|         try
 | |
|         {
 | |
|           var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
 | |
|           await connection.ExecuteAsync(asociacionQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
 | |
|         }
 | |
|         catch (SqlException ex) when (ex.Number == 2627)
 | |
|         {
 | |
|           return Conflict("El usuario ya está asociado a este equipo.");
 | |
|         }
 | |
| 
 | |
|         return Ok(new { message = "Usuario asociado manualmente." });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     [HttpGet("distinct/{fieldName}")]
 | |
|     public async Task<IActionResult> GetDistinctFieldValues(string fieldName)
 | |
|     {
 | |
|       // 1. Lista blanca de campos permitidos para evitar inyección SQL y exposición de datos.
 | |
|       var allowedFields = new List<string> { "os", "cpu", "motherboard", "architecture" };
 | |
| 
 | |
|       if (!allowedFields.Contains(fieldName.ToLower()))
 | |
|       {
 | |
|         return BadRequest("El campo especificado no es válido o no está permitido.");
 | |
|       }
 | |
| 
 | |
|       // 2. Construir la consulta de forma segura
 | |
|       var query = $"SELECT DISTINCT {fieldName} FROM dbo.equipos WHERE {fieldName} IS NOT NULL AND {fieldName} != '' ORDER BY {fieldName};";
 | |
| 
 | |
|       using (var connection = _context.CreateConnection())
 | |
|       {
 | |
|         var values = await connection.QueryAsync<string>(query);
 | |
|         return Ok(values);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // DTOs locales para las peticiones
 | |
|     public class PingRequestDto { public string? Ip { get; set; } }
 | |
| 
 | |
|     public class WolRequestDto
 | |
|     {
 | |
|       public string? Mac { get; set; }
 | |
|       public string? Ip { get; set; }
 | |
|     }
 | |
| 
 | |
|     class DiscoAsociado
 | |
|     {
 | |
|       public int Id { get; set; }
 | |
|       public string Mediatype { get; set; } = "";
 | |
|       public int Size { get; set; }
 | |
|       public int EquipoDiscoId { get; set; }
 | |
|     }
 | |
| 
 | |
|     public class CrearEquipoManualDto
 | |
|     {
 | |
|       public required string Hostname { get; set; }
 | |
|       public required string Ip { get; set; }
 | |
|       public string? Motherboard { get; set; }
 | |
|       public string? Cpu { get; set; }
 | |
|       public string? Os { get; set; }
 | |
|       public int? Sector_id { get; set; }
 | |
|     }
 | |
| 
 | |
|     public class EditarEquipoManualDto
 | |
|     {
 | |
|       public required string Hostname { get; set; }
 | |
|       public required string Ip { get; set; }
 | |
|       public string? Mac { get; set; }
 | |
|       public string? Motherboard { get; set; }
 | |
|       public string? Cpu { get; set; }
 | |
|       public string? Os { get; set; }
 | |
|       public int? Sector_id { get; set; }
 | |
|     }
 | |
| 
 | |
|     public class AsociarDiscoManualDto
 | |
|     {
 | |
|       public required string Mediatype { get; set; }
 | |
|       public int Size { get; set; }
 | |
|     }
 | |
| 
 | |
|     public class AsociarRamManualDto
 | |
|     {
 | |
|       public required string Slot { get; set; }
 | |
|       public int Tamano { get; set; }
 | |
|       public string? Fabricante { get; set; }
 | |
|       public int? Velocidad { get; set; }
 | |
|     }
 | |
| 
 | |
|     public class AsociarUsuarioManualDto
 | |
|     {
 | |
|       public required string Username { get; set; }
 | |
|     }
 | |
|   }
 | |
| } |