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 // backend/src/Titulares.Api/Hubs/TitularesHub.cs
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Titulares.Api.Services;
namespace Titulares.Api.Hubs; 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 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; namespace Titulares.Api.Services;
public class EstadoProcesoService public class EstadoProcesoService
{ {
private volatile bool _estaActivo = false; 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 event Action? OnStateChanged;
public bool EstaActivo() => _estaActivo; public bool EstaActivo() => _estaActivo;
@@ -14,14 +13,38 @@ public class EstadoProcesoService
public void Activar() public void Activar()
{ {
_estaActivo = true; _estaActivo = true;
// 2. Disparamos el evento para notificar a los suscriptores.
OnStateChanged?.Invoke(); OnStateChanged?.Invoke();
} }
public void Desactivar() public void Desactivar()
{ {
_estaActivo = false; _estaActivo = false;
// 3. Disparamos el evento también al desactivar.
OnStateChanged?.Invoke(); 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 { useEffect, useState, useCallback } from 'react';
import { import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material';
Box, Button, Stack, Chip, CircularProgress,
Accordion, AccordionSummary, AccordionDetails, Typography
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import SyncIcon from '@mui/icons-material/Sync'; import SyncIcon from '@mui/icons-material/Sync';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -18,6 +17,7 @@ import EditarTitularModal from './EditarTitularModal';
import { PowerSwitch } from './PowerSwitch'; import { PowerSwitch } from './PowerSwitch';
import ConfirmationModal from './ConfirmationModal'; import ConfirmationModal from './ConfirmationModal';
import type { ActualizarTitularPayload } from '../services/apiService'; import type { ActualizarTitularPayload } from '../services/apiService';
import { TableSkeleton } from './TableSkeleton';
const Dashboard = () => { const Dashboard = () => {
const [titulares, setTitulares] = useState<Titular[]>([]); const [titulares, setTitulares] = useState<Titular[]>([]);
@@ -26,9 +26,10 @@ const Dashboard = () => {
const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); const [isGeneratingCsv, setIsGeneratingCsv] = useState(false);
const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null }); const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null });
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null); const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null);
const [isLoading, setIsLoading] = useState(true);
const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => {
setTitulares(titularesActualizados); setTitulares(titularesActualizados);
}, []); }, []);
@@ -38,11 +39,15 @@ const Dashboard = () => {
]); ]);
useEffect(() => { useEffect(() => {
// 1. Cargamos la configuración persistente
const fetchConfig = api.obtenerConfiguracion(); const fetchConfig = api.obtenerConfiguracion();
// 2. Preguntamos al servidor por el estado ACTUAL del proceso
const fetchEstado = api.getEstadoProceso(); const fetchEstado = api.getEstadoProceso();
Promise.all([fetchConfig, fetchEstado]) Promise.all([fetchConfig, fetchEstado])
.then(([configData, estadoData]) => { .then(([configData, estadoData]) => {
// Construimos el estado de la UI para que REFLEJE el estado real del servidor
setConfig({ setConfig({
...configData, ...configData,
scrapingActivo: estadoData.activo scrapingActivo: estadoData.activo
@@ -50,8 +55,13 @@ const Dashboard = () => {
}) })
.catch(error => console.error("Error al cargar datos iniciales:", error)); .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 handleDelete = (id: number) => {
const onConfirm = async () => { const onConfirm = async () => {
@@ -188,13 +198,17 @@ const Dashboard = () => {
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
<TablaTitulares {isLoading ? (
titulares={titulares} <TableSkeleton />
onReorder={handleReorder} ) : (
onDelete={handleDelete} <TablaTitulares
onEdit={(titular) => setTitularAEditar(titular)} titulares={titulares}
onSave={handleSaveEdit} onReorder={handleReorder}
/> onDelete={handleDelete}
onEdit={(titular) => setTitularAEditar(titular)}
onSave={handleSaveEdit}
/>
)}
<AddTitularModal <AddTitularModal
open={addModalOpen} 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>
);
};