Files
SYNC_API/sync_api/routers/nodes.py

563 lines
22 KiB
Python

from fastapi import APIRouter, HTTPException, Path, Query
from typing import List, Optional
import logging
import os
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"
def determine_sync_status(node_name: str, last_restore_date: Optional[datetime] = None) -> str:
"""
Determina el estado de sincronización basado en la ubicación de los archivos ZIP del nodo.
Lógica:
- Si existe en D:\sftp -> "Recibido"
- Si existe en D:\BackupSFTP -> "Procesando"
- Si la fecha de última sincronización es mayor a la del archivo en D:\BackupSFTP -> "Actualizada"
- En otros casos sigue la lógica anterior basada en fechas
"""
try:
sftp_path = f"D:\\sftp\\{node_name}.zip"
backup_sftp_path = f"D:\\BackupSFTP\\{node_name}.zip"
# Verificar si existe en D:\sftp (prioridad más alta)
if os.path.exists(sftp_path):
return EstadoSincronizacion.RECIBIDO.value
# Verificar si existe en D:\BackupSFTP
if os.path.exists(backup_sftp_path):
# Si tenemos fecha de última restauración, comparar
if last_restore_date:
try:
# Obtener fecha de modificación del archivo
file_mod_time = datetime.fromtimestamp(os.path.getmtime(backup_sftp_path))
# Si la última sincronización es más reciente que el archivo
if last_restore_date > file_mod_time:
return EstadoSincronizacion.ACTUALIZADA.value
# Si la última restauración fue hace más de 5 horas, continuar
if datetime.now() - last_restore_date > timedelta(hours=5):
# Pasaron más de 5 horas, continuar el proceso
pass
else:
# Aún dentro de las 5 horas → seguir procesando
return EstadoSincronizacion.PROCESANDO.value
except OSError:
# Si hay error leyendo el archivo, asumir procesando
return EstadoSincronizacion.PROCESANDO.value
# Si no existe en ninguna ruta SFTP, usar lógica basada en fechas de restauración
if last_restore_date is None:
return EstadoSincronizacion.ERROR.value
elif last_restore_date < datetime.now() - timedelta(days=7):
return EstadoSincronizacion.ERROR.value
elif last_restore_date < datetime.now() - timedelta(hours=24):
return EstadoSincronizacion.ATENCION.value
else:
return EstadoSincronizacion.ACTUALIZADA.value
except Exception as e:
logger.error(f"Error determinando estado de sincronización para {node_name}: {str(e)}")
# En caso de error, usar lógica de fechas como fallback
if last_restore_date is None:
return EstadoSincronizacion.ERROR.value
elif last_restore_date < datetime.now() - timedelta(days=7):
return EstadoSincronizacion.ERROR.value
elif last_restore_date < datetime.now() - timedelta(hours=24):
return EstadoSincronizacion.ATENCION.value
else:
return EstadoSincronizacion.ACTUALIZADA.value
@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 ubicación de archivos y fecha de restauración
estado = determine_sync_status(node_name, last_restore_date)
# 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 ubicación de archivos y fecha de restauración
estado_sync = determine_sync_status(nodo_name, last_restore)
# 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)}"
)