from fastapi import APIRouter, HTTPException, Path, Query from typing import List, Optional import logging from datetime import datetime, timedelta from database.connection import db_connection from models.sync_models import ( NodeStatus, NodesList, NodeSummary, BackupInfo, ErrorResponse, EstadoSincronizacion ) router = APIRouter(prefix="/api/nodes", tags=["nodes"]) logger = logging.getLogger(__name__) def calculate_time_since_sync(last_sync: Optional[datetime]) -> Optional[str]: """Calcula el tiempo transcurrido desde la última sincronización""" if not last_sync: return None now = datetime.now() if last_sync.tzinfo is None: # Si last_sync no tiene timezone, asumimos que está en la zona local diff = now - last_sync else: # Si tiene timezone, convertimos now a timezone-aware from datetime import timezone now = now.replace(tzinfo=timezone.utc) diff = now - last_sync total_seconds = int(diff.total_seconds()) if total_seconds < 60: return f"{total_seconds} segundos" elif total_seconds < 3600: minutes = total_seconds // 60 return f"{minutes} minutos" elif total_seconds < 86400: hours = total_seconds // 3600 return f"{hours} horas" else: days = total_seconds // 86400 return f"{days} días" @router.get("/{node_name}/status", response_model=NodeStatus, summary="Obtener estado específico de un nodo", description="Retorna información detallada del estado de sincronización de un nodo específico") async def get_node_status( node_name: str = Path(..., description="Nombre del nodo a consultar", example="NODO001") ): """ Obtiene el estado detallado de sincronización de un nodo específico. Incluye: - Estado de sincronización actual - Fecha de última sincronización - Contadores de clientes (total, activos, inactivos) - Tiempo transcurrido desde última sincronización """ try: logger.info(f"Consultando estado del nodo: {node_name}") # Paso 1: Obtener información del cliente desde CONTROLDESK client_query = """ SELECT NodoSubNodo, Nombre, BDName, Activo, CorreoNotificacion FROM BasesdeDatos WHERE NodoSubNodo = ? OR BDName = ? """ client_results = db_connection.execute_query(client_query, (node_name, node_name), server_type="main") if not client_results: raise HTTPException( status_code=404, detail=f"Nodo '{node_name}' no encontrado en CONTROLDESK" ) client_info = client_results[0] # Paso 2: Obtener información de restauración desde servidor de backup restore_query = """ SELECT d.name AS database_name, MAX(rh.restore_date) AS last_restore_date, SUM(mf.size * 8 / 1024) AS size_mb FROM sys.databases d LEFT JOIN sys.master_files mf ON d.database_id = mf.database_id LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name WHERE mf.type = 0 AND d.name != 'tempdb' AND (d.name = ? OR d.name LIKE '%' + ? + '%') GROUP BY d.name """ restore_results = db_connection.execute_query(restore_query, (client_info['BDName'], node_name), server_type="backup") # Combinar resultados last_restore_date = None size_mb = 0 if restore_results: restore_info = restore_results[0] last_restore_date = restore_info.get('last_restore_date') size_mb = restore_info.get('size_mb', 0) # Determinar estado basado en fecha de restauración if last_restore_date is None: estado = 'Error' elif last_restore_date < datetime.now() - timedelta(days=7): estado = 'Error' elif last_restore_date < datetime.now() - timedelta(hours=24): estado = 'Atención' else: estado = 'Actualizada' # Calcular tiempo desde última sincronización time_since_sync = calculate_time_since_sync(last_restore_date) node_status = NodeStatus( nodo_sub_nodo=client_info['NodoSubNodo'], estado_sincronizacion=EstadoSincronizacion(estado), ultima_sincronizacion=last_restore_date, total_clientes=1, clientes_activos=1 if client_info.get('Activo', 0) == 1 else 0, clientes_inactivos=0 if client_info.get('Activo', 0) == 1 else 1, tiempo_desde_ultima_sync=time_since_sync ) logger.info(f"Estado del nodo {node_name} obtenido exitosamente") return node_status except HTTPException: raise except Exception as e: logger.error(f"Error obteniendo estado del nodo {node_name}: {str(e)}") raise HTTPException( status_code=500, detail=f"Error interno del servidor al consultar nodo: {str(e)}" ) @router.get("", response_model=NodesList, summary="Listar todos los nodos", description="Retorna una lista de todos los nodos con su estado de sincronización") async def get_all_nodes( estado: Optional[EstadoSincronizacion] = Query(None, description="Filtrar por estado de sincronización"), activos_solo: bool = Query(False, description="Mostrar solo clientes activos"), limit: int = Query(100, ge=1, le=1000, description="Límite de resultados"), offset: int = Query(0, ge=0, description="Offset para paginación") ): """ Obtiene una lista de todos los nodos con información resumida. Parámetros opcionales: - estado: Filtrar por estado específico (Actualizada, Atención, Error) - activos_solo: Si es True, solo considera clientes activos en los conteos - limit: Número máximo de nodos a retornar - offset: Número de nodos a saltar (para paginación) """ try: logger.info(f"Consultando lista de nodos - Estado: {estado}, Activos solo: {activos_solo}") # Paso 1: Obtener todos los clientes desde CONTROLDESK clients_query = """ SELECT NodoSubNodo, Nombre, BDName, Activo, CorreoNotificacion FROM BasesdeDatos ORDER BY NodoSubNodo OFFSET ? ROWS FETCH NEXT ? ROWS ONLY """ client_results = db_connection.execute_query(clients_query, (offset, limit), server_type="main") # Paso 2: Obtener información de restauración para todas las bases de datos restore_query = """ SELECT d.name AS database_name, MAX(rh.restore_date) AS last_restore_date FROM sys.databases d LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name WHERE d.name != 'tempdb' GROUP BY d.name """ restore_results = db_connection.execute_query(restore_query, server_type="backup") # Crear mapa de restauraciones para búsqueda rápida restore_map = {row['database_name']: row['last_restore_date'] for row in restore_results} # Combinar información de clientes con restauraciones nodes = [] for client in client_results: # Buscar información de restauración bd_name = client['BDName'] nodo_name = client['NodoSubNodo'] # Buscar por nombre de BD o nodo last_restore = restore_map.get(bd_name) or restore_map.get(nodo_name) # Determinar estado basado en fecha de restauración if last_restore is None: estado_sync = 'Error' elif last_restore < datetime.now() - timedelta(days=7): estado_sync = 'Error' elif last_restore < datetime.now() - timedelta(hours=24): estado_sync = 'Atención' else: estado_sync = 'Actualizada' # Aplicar filtros if estado and estado_sync != estado.value: continue if activos_solo and client.get('Activo', 0) == 0: continue node_summary = NodeSummary( nodo_sub_nodo=client['NodoSubNodo'], estado_sincronizacion=EstadoSincronizacion(estado_sync), ultima_sincronizacion=last_restore, total_clientes=1, clientes_activos=1 if client.get('Activo', 0) == 1 else 0 ) nodes.append(node_summary) # Calcular conteos por estado desde los nodos procesados nodes_by_status = {} for node in nodes: estado_str = node.estado_sincronizacion.value nodes_by_status[estado_str] = nodes_by_status.get(estado_str, 0) + 1 node_list = NodesList( nodes=nodes, total_nodes=len(nodes), nodes_by_status=nodes_by_status, last_updated=datetime.now() ) logger.info(f"Lista de nodos obtenida exitosamente. Total: {len(nodes)}") return node_list except Exception as e: logger.error(f"Error obteniendo lista de nodos: {str(e)}") raise HTTPException( status_code=500, detail=f"Error interno del servidor al consultar nodos: {str(e)}" ) @router.get("/databases/restore-status", summary="Obtener estado de restauración de todas las bases de datos", description="Retorna información de restauración similar al panel PHP") async def get_databases_restore_status(): """ Obtiene el estado de restauración de todas las bases de datos del servidor, replicando la funcionalidad del panel PHP. """ try: logger.info("Consultando estado de restauración de bases de datos") # Consulta exacta del archivo PHP query = """ SELECT d.name AS visible_name, REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name, SUM(mf.size * 8 / 1024) AS size_mb, MAX(rh.restore_date) AS last_restore_date FROM sys.databases d LEFT JOIN sys.master_files mf ON d.database_id = mf.database_id LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name WHERE mf.type = 0 AND d.name != 'tempdb' GROUP BY d.name, mf.physical_name ORDER BY d.name """ results = db_connection.execute_query(query, server_type="backup") # Procesar resultados databases = [] restored_count = 0 not_restored_count = 0 for row in results: last_restore = row.get('last_restore_date') is_recent = False if last_restore: # Verificar si fue restaurada en las últimas 24 horas if isinstance(last_restore, datetime): is_recent = last_restore > datetime.now() - timedelta(days=1) else: # Si es string, intentar parsear try: restore_dt = datetime.fromisoformat(str(last_restore).replace('Z', '+00:00')) is_recent = restore_dt > datetime.now() - timedelta(days=1) except: is_recent = False if is_recent: restored_count += 1 else: not_restored_count += 1 # Calcular tiempo desde última restauración time_since_restore = None if last_restore: time_since_restore = calculate_time_since_sync(last_restore) database_info = { "visible_name": row['visible_name'], "original_name": row.get('original_name', 'N/A'), "size_mb": round(row.get('size_mb', 0), 2), "last_restore_date": last_restore.isoformat() if last_restore else None, "is_recent_restore": is_recent, "time_since_restore": time_since_restore, "status": "Actualizada" if is_recent else ("Atención" if last_restore else "Error") } databases.append(database_info) response = { "databases": databases, "summary": { "total_databases": len(databases), "restored_recent": restored_count, "not_restored_recent": not_restored_count, "last_check": datetime.now().isoformat() } } logger.info(f"Estado de restauración obtenido. Total: {len(databases)} bases de datos") return response except Exception as e: logger.error(f"Error obteniendo estado de restauración: {str(e)}") raise HTTPException( status_code=500, detail=f"Error interno del servidor: {str(e)}" ) @router.get("/{node_name}/client-info", summary="Obtener información del cliente por nodo", description="Retorna información detallada del cliente asociado al nodo") async def get_node_client_info( node_name: str = Path(..., description="Nombre del nodo", example="NODO001") ): """ Obtiene información detallada del cliente asociado a un nodo específico. """ try: logger.info(f"Consultando información del cliente para nodo: {node_name}") # Consulta para obtener información completa del cliente query = """ SELECT bd.ID, bd.Nombre, bd.NodoSubNodo, bd.BDName, bd.CorreoNotificacion, bd.Activo, u.ClienteAutoridad, u.Usuario, u.BD_Shelter FROM BasesdeDatos bd LEFT JOIN Usuarios u ON bd.NodoSubNodo = u.Usuario WHERE bd.NodoSubNodo = ? OR bd.BDName = ? """ results = db_connection.execute_query(query, (node_name, node_name), server_type="main") if not results: raise HTTPException( status_code=404, detail=f"No se encontró información para el nodo '{node_name}'" ) row = results[0] response = { "id": row.get('ID'), "nombre_cliente": row.get('Nombre'), "nodo_sub_nodo": row.get('NodoSubNodo'), "bd_name": row.get('BDName'), "correo_notificacion": row.get('CorreoNotificacion'), "activo": bool(row.get('Activo', False)), "cliente_autoridad": row.get('ClienteAutoridad'), "usuario": row.get('Usuario'), "bd_shelter": row.get('BD_Shelter'), "estado": "Activo" if row.get('Activo') else "Inactivo" } logger.info(f"Información del cliente para {node_name} obtenida exitosamente") return response except HTTPException: raise except Exception as e: logger.error(f"Error obteniendo información del cliente para {node_name}: {str(e)}") raise HTTPException( status_code=500, detail=f"Error interno del servidor: {str(e)}" ) @router.get("/{node_name}/backup-info", response_model=BackupInfo, summary="Obtener información de respaldos de un nodo", description="Retorna información sobre los respaldos de un nodo específico") async def get_node_backup_info( node_name: str = Path(..., description="Nombre del nodo", example="NODO001") ): """ Obtiene información sobre los respaldos de un nodo específico. NOTA: Este endpoint es opcional y requiere configuración adicional de las tablas de respaldo en SQL Server. """ try: logger.info(f"Consultando información de backup para nodo: {node_name}") # Query básica para información de backup (requiere tablas adicionales) # Esta es una implementación básica que asume una estructura de backup query = """ SELECT TOP 1 ? as NodoSubNodo, backup_start_date as UltimoBackup, type as TipoBackup, compressed_backup_size / 1024.0 / 1024.0 as TamanoBackupMB, 'Completed' as EstadoBackup, physical_device_name as RutaBackup FROM msdb.dbo.backupset bs INNER JOIN msdb.dbo.backupmediafamily bmf ON bs.media_set_id = bmf.media_set_id WHERE database_name IN ( SELECT DISTINCT BDName FROM Clientes WHERE NodoSubNodo = ? ) ORDER BY backup_start_date DESC """ try: results = db_connection.execute_query(query, (node_name, node_name)) except Exception: # Si no existe la información de backup, devolver información básica logger.warning(f"No se encontró información de backup para {node_name}") return BackupInfo( nodo_sub_nodo=node_name, ultimo_backup=None, tipo_backup="No disponible", tamano_backup="No disponible", estado_backup="No configurado", ruta_backup="No disponible", dias_desde_backup=None ) if not results: return BackupInfo( nodo_sub_nodo=node_name, ultimo_backup=None, tipo_backup="Sin respaldos", tamano_backup="0 MB", estado_backup="Sin datos", ruta_backup="N/A", dias_desde_backup=None ) row = results[0] ultimo_backup = row.get('UltimoBackup') # Calcular días desde el último backup dias_desde_backup = None if ultimo_backup: dias_desde_backup = (datetime.now() - ultimo_backup).days backup_info = BackupInfo( nodo_sub_nodo=node_name, ultimo_backup=ultimo_backup, tipo_backup=row.get('TipoBackup', 'Unknown'), tamano_backup=f"{row.get('TamanoBackupMB', 0):.2f} MB", estado_backup=row.get('EstadoBackup', 'Unknown'), ruta_backup=row.get('RutaBackup', 'Unknown'), dias_desde_backup=dias_desde_backup ) logger.info(f"Información de backup para {node_name} obtenida exitosamente") return backup_info except HTTPException: raise except Exception as e: logger.error(f"Error obteniendo información de backup para {node_name}: {str(e)}") raise HTTPException( status_code=500, detail=f"Error interno del servidor al consultar backup: {str(e)}" )