Feat Skeleton Table de Carga - Fix Freno del Proceso al Cerrar Web
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
51
frontend/src/components/TableSkeleton.tsx
Normal file
51
frontend/src/components/TableSkeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user