Feat Skeleton Table de Carga - Fix Freno del Proceso al Cerrar Web

This commit is contained in:
2025-10-29 14:16:57 -03:00
parent 66e3a0af99
commit e41892ef2d
4 changed files with 128 additions and 22 deletions

View File

@@ -1,12 +1,30 @@
// backend/src/Titulares.Api/Hubs/TitularesHub.cs
using Microsoft.AspNetCore.SignalR;
using Titulares.Api.Services;
namespace Titulares.Api.Hubs;
// Esta clase es el punto de conexión para los clientes de SignalR.
// No necesitamos añadirle métodos personalizados porque solo enviaremos
// mensajes desde el servidor hacia los clientes.
public class TitularesHub : Hub
{
private readonly EstadoProcesoService _estadoService;
public TitularesHub(EstadoProcesoService estadoService)
{
_estadoService = estadoService;
}
// Este método se ejecuta CADA VEZ que un nuevo cliente (pestaña) se conecta.
public override Task OnConnectedAsync()
{
_estadoService.RegistrarConexion();
return base.OnConnectedAsync();
}
// Este método se ejecuta CADA VEZ que un cliente (pestaña) se desconecta.
public override Task OnDisconnectedAsync(Exception? exception)
{
_estadoService.RegistrarDesconexionYApagarSiEsElUltimo();
return base.OnDisconnectedAsync(exception);
}
}

View File

@@ -1,12 +1,11 @@
// backend/src/Titulares.Api/Services/EstadoProcesoService.cs
namespace Titulares.Api.Services;
public class EstadoProcesoService
{
private volatile bool _estaActivo = false;
private volatile int _connectionCount = 0;
private readonly object _lock = new object();
// 1. Definimos un evento público al que otros servicios pueden suscribirse.
public event Action? OnStateChanged;
public bool EstaActivo() => _estaActivo;
@@ -14,14 +13,38 @@ public class EstadoProcesoService
public void Activar()
{
_estaActivo = true;
// 2. Disparamos el evento para notificar a los suscriptores.
OnStateChanged?.Invoke();
}
public void Desactivar()
{
_estaActivo = false;
// 3. Disparamos el evento también al desactivar.
OnStateChanged?.Invoke();
}
public void RegistrarConexion()
{
lock (_lock)
{
_connectionCount++;
}
}
public void RegistrarDesconexionYApagarSiEsElUltimo()
{
lock (_lock)
{
_connectionCount--;
// Si el contador llega a 0, significa que no hay más clientes conectados.
// Apagamos el proceso de forma segura.
if (_connectionCount <= 0)
{
_connectionCount = 0; // Prevenir números negativos
if (_estaActivo)
{
Desactivar();
}
}
}
}
}

View File

@@ -1,8 +1,7 @@
// frontend/src/components/Dashboard.tsx
import { useEffect, useState, useCallback } from 'react';
import {
Box, Button, Stack, Chip, CircularProgress,
Accordion, AccordionSummary, AccordionDetails, Typography
} from '@mui/material';
import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import SyncIcon from '@mui/icons-material/Sync';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -18,6 +17,7 @@ import EditarTitularModal from './EditarTitularModal';
import { PowerSwitch } from './PowerSwitch';
import ConfirmationModal from './ConfirmationModal';
import type { ActualizarTitularPayload } from '../services/apiService';
import { TableSkeleton } from './TableSkeleton';
const Dashboard = () => {
const [titulares, setTitulares] = useState<Titular[]>([]);
@@ -26,9 +26,10 @@ const Dashboard = () => {
const [isGeneratingCsv, setIsGeneratingCsv] = useState(false);
const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null });
const { showNotification } = useNotification();
const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null);
const [isLoading, setIsLoading] = useState(true);
const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => {
setTitulares(titularesActualizados);
}, []);
@@ -38,11 +39,15 @@ const Dashboard = () => {
]);
useEffect(() => {
// 1. Cargamos la configuración persistente
const fetchConfig = api.obtenerConfiguracion();
// 2. Preguntamos al servidor por el estado ACTUAL del proceso
const fetchEstado = api.getEstadoProceso();
Promise.all([fetchConfig, fetchEstado])
.then(([configData, estadoData]) => {
// Construimos el estado de la UI para que REFLEJE el estado real del servidor
setConfig({
...configData,
scrapingActivo: estadoData.activo
@@ -50,8 +55,13 @@ const Dashboard = () => {
})
.catch(error => console.error("Error al cargar datos iniciales:", error));
api.obtenerTitulares().then(setTitulares);
}, []);
// La carga de titulares sigue igual
api.obtenerTitulares()
.then(setTitulares)
.catch(error => console.error("Error al cargar titulares:", error))
.finally(() => setIsLoading(false));
}, []); // El array vacío asegura que esto solo se ejecute una vez
const handleDelete = (id: number) => {
const onConfirm = async () => {
@@ -188,6 +198,9 @@ const Dashboard = () => {
</AccordionDetails>
</Accordion>
{isLoading ? (
<TableSkeleton />
) : (
<TablaTitulares
titulares={titulares}
onReorder={handleReorder}
@@ -195,6 +208,7 @@ const Dashboard = () => {
onEdit={(titular) => setTitularAEditar(titular)}
onSave={handleSaveEdit}
/>
)}
<AddTitularModal
open={addModalOpen}

View File

@@ -0,0 +1,51 @@
// frontend/src/components/TableSkeleton.tsx
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Skeleton } from '@mui/material';
// Un componente para una única fila de esqueleto
const SkeletonRow = () => (
<TableRow>
<TableCell sx={{ padding: '8px 16px', width: 50 }}>
<Skeleton variant="circular" width={24} height={24} />
</TableCell>
<TableCell sx={{ padding: '8px 16px' }}>
<Skeleton variant="text" sx={{ fontSize: '1rem' }} />
</TableCell>
<TableCell sx={{ padding: '8px 16px' }}>
<Skeleton variant="rounded" width={60} height={22} />
</TableCell>
<TableCell sx={{ padding: '8px 16px' }}>
<Skeleton variant="text" width={80} />
</TableCell>
<TableCell sx={{ padding: '8px 16px' }} align="right">
<Skeleton variant="text" width={60} />
</TableCell>
</TableRow>
);
// El componente principal que renderiza la tabla fantasma
export const TableSkeleton = () => {
return (
<Paper elevation={0} sx={{ overflow: 'hidden' }}>
<TableContainer>
<Table>
<TableHead>
<TableRow sx={{ '& .MuiTableCell-root': { padding: '6px 16px', borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}>
<TableCell sx={{ width: 50 }} />
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell>
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell>
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Fuente</TableCell>
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{/* Creamos un array de 5 elementos para renderizar 5 filas de esqueleto */}
{[...Array(5)].map((_, index) => (
<SkeletonRow key={index} />
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
};