Dashboard design improved
This commit is contained in:
@@ -1,178 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
|
||||
import KPICards from '@/components/dashboard/KPICards'
|
||||
import DeviceGrid from '@/components/dashboard/DeviceGrid'
|
||||
import AlertsFeed from '@/components/dashboard/AlertsFeed'
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Monitor, CheckCircle, XCircle, AlertTriangle, Plus } from 'lucide-react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import SummaryCard from '@/components/dashboard/SummaryCard'
|
||||
import DeviceStatusChart from '@/components/dashboard/DeviceStatusChart'
|
||||
import RecentActivityList from '@/components/dashboard/RecentActivityList'
|
||||
import SystemHealthCard from '@/components/dashboard/SystemHealthCard'
|
||||
import QuickConnectionCard from '@/components/dashboard/QuickConnectionCard'
|
||||
import {
|
||||
MOCK_DASHBOARD_SECONDARY,
|
||||
MOCK_SYSTEM_HEALTH,
|
||||
MOCK_QUICK_CONNECTIONS,
|
||||
} from '@/mocks/dashboardData'
|
||||
import type {
|
||||
QuickConnectionItem,
|
||||
QuickConnectionStatus,
|
||||
RecentActivityItem,
|
||||
} from '@/mocks/dashboardData'
|
||||
|
||||
type DeviceForGrid = {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
estado: string
|
||||
ip?: string | null
|
||||
sistemaOperativo?: string | null
|
||||
lastSeen?: Date | null
|
||||
cpuUsage?: number | null
|
||||
ramUsage?: number | null
|
||||
cliente?: { nombre: string }
|
||||
}
|
||||
|
||||
type DashboardAlert = {
|
||||
id: string
|
||||
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
|
||||
titulo: string
|
||||
mensaje: string
|
||||
createdAt: Date
|
||||
dispositivo: { nombre: string }
|
||||
cliente: { nombre: string }
|
||||
}
|
||||
|
||||
// Mock data - en produccion vendria de la API
|
||||
const mockStats = {
|
||||
totalDispositivos: 127,
|
||||
dispositivosOnline: 98,
|
||||
dispositivosOffline: 24,
|
||||
dispositivosAlerta: 5,
|
||||
alertasActivas: 8,
|
||||
alertasCriticas: 2,
|
||||
sesionesActivas: 3,
|
||||
}
|
||||
|
||||
const mockDevices = [
|
||||
{
|
||||
id: '1',
|
||||
nombre: 'SRV-PRINCIPAL',
|
||||
tipo: 'SERVIDOR',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.10',
|
||||
sistemaOperativo: 'Windows Server 2022',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: 45,
|
||||
ramUsage: 72,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nombre: 'PC-ADMIN-01',
|
||||
tipo: 'PC',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.101',
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: 23,
|
||||
ramUsage: 56,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nombre: 'LAPTOP-VENTAS',
|
||||
tipo: 'LAPTOP',
|
||||
estado: 'ALERTA',
|
||||
ip: '192.168.1.105',
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
lastSeen: new Date(Date.now() - 1000 * 60 * 5),
|
||||
cpuUsage: 95,
|
||||
ramUsage: 88,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nombre: 'ROUTER-PRINCIPAL',
|
||||
tipo: 'ROUTER',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.1',
|
||||
sistemaOperativo: 'RouterOS 7.12',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nombre: 'SW-CORE-01',
|
||||
tipo: 'SWITCH',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.2',
|
||||
sistemaOperativo: 'Cisco IOS',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
nombre: 'CELULAR-GERENTE',
|
||||
tipo: 'CELULAR',
|
||||
estado: 'ONLINE',
|
||||
ip: null,
|
||||
sistemaOperativo: 'Android 14',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
nombre: 'SRV-BACKUP',
|
||||
tipo: 'SERVIDOR',
|
||||
estado: 'OFFLINE',
|
||||
ip: '192.168.1.11',
|
||||
sistemaOperativo: 'Ubuntu 22.04',
|
||||
lastSeen: new Date(Date.now() - 1000 * 60 * 60 * 2),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
nombre: 'AP-OFICINA-01',
|
||||
tipo: 'AP',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.50',
|
||||
sistemaOperativo: 'UniFi AP',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
]
|
||||
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
severidad: 'CRITICAL' as const,
|
||||
estado: 'ACTIVA' as const,
|
||||
titulo: 'Servidor de backup offline',
|
||||
mensaje: 'El servidor SRV-BACKUP no responde desde hace 2 horas',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 120),
|
||||
dispositivo: { nombre: 'SRV-BACKUP' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
severidad: 'WARNING' as const,
|
||||
estado: 'ACTIVA' as const,
|
||||
titulo: 'CPU alta',
|
||||
mensaje: 'Uso de CPU al 95% en LAPTOP-VENTAS',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 15),
|
||||
dispositivo: { nombre: 'LAPTOP-VENTAS' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
severidad: 'INFO' as const,
|
||||
estado: 'RECONOCIDA' as const,
|
||||
titulo: 'Actualizacion disponible',
|
||||
mensaje: 'Windows Update pendiente en PC-ADMIN-01',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60),
|
||||
dispositivo: { nombre: 'PC-ADMIN-01' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
]
|
||||
|
||||
const DEVICES_LIMIT = 12
|
||||
const DEVICES_LIMIT = 8
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const utils = trpc.useUtils()
|
||||
const { selectedClientId } = useSelectedClient()
|
||||
const clienteId = selectedClientId ?? undefined
|
||||
|
||||
@@ -180,38 +31,6 @@ export default function DashboardPage() {
|
||||
{ clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
const stats = statsQuery.data ?? mockStats
|
||||
|
||||
const alertsQuery = trpc.alertas.list.useQuery(
|
||||
{ page: 1, limit: 25, clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
const alerts: DashboardAlert[] = useMemo(() => {
|
||||
const list = alertsQuery.data?.alertas ?? []
|
||||
return list.map((a) => ({
|
||||
id: a.id,
|
||||
severidad: a.severidad,
|
||||
estado: a.estado,
|
||||
titulo: a.titulo,
|
||||
mensaje: a.mensaje,
|
||||
createdAt: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||
dispositivo: a.dispositivo ? { nombre: a.dispositivo.nombre } : { nombre: '—' },
|
||||
cliente: { nombre: a.cliente.nombre },
|
||||
}))
|
||||
}, [alertsQuery.data])
|
||||
|
||||
const acknowledgeMutation = trpc.alertas.reconocer.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.alertas.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
const resolveMutation = trpc.alertas.resolver.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.alertas.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const equiposQuery = trpc.equipos.list.useQuery(
|
||||
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
||||
@@ -226,147 +45,155 @@ export default function DashboardPage() {
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const devices: DeviceForGrid[] = useMemo(() => {
|
||||
const eq = equiposQuery.data?.dispositivos ?? []
|
||||
const rd = redQuery.data?.dispositivos ?? []
|
||||
const cel = celularesQuery.data?.dispositivos ?? []
|
||||
const all = [...eq, ...rd, ...cel]
|
||||
return all.map((d) => ({
|
||||
id: d.id,
|
||||
nombre: d.nombre,
|
||||
tipo: d.tipo,
|
||||
estado: d.estado,
|
||||
ip: d.ip ?? null,
|
||||
sistemaOperativo: d.sistemaOperativo ?? null,
|
||||
lastSeen: d.lastSeen ?? null,
|
||||
cpuUsage: d.cpuUsage ?? null,
|
||||
ramUsage: d.ramUsage ?? null,
|
||||
cliente: d.cliente ? { nombre: d.cliente.nombre } : undefined,
|
||||
const alertsQuery = trpc.alertas.list.useQuery(
|
||||
{ page: 1, limit: 15, clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const recentActivity: RecentActivityItem[] = useMemo(() => {
|
||||
const list = alertsQuery.data?.alertas ?? []
|
||||
return list.map((a) => ({
|
||||
id: a.id,
|
||||
type: 'alert' as const,
|
||||
description: a.titulo,
|
||||
deviceName: a.dispositivo?.nombre ?? '—',
|
||||
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||
severity: a.severidad,
|
||||
}))
|
||||
}, [equiposQuery.data, redQuery.data, celularesQuery.data])
|
||||
}, [alertsQuery.data])
|
||||
|
||||
const devicesLoading =
|
||||
equiposQuery.isLoading || redQuery.isLoading || celularesQuery.isLoading
|
||||
const isRefreshing =
|
||||
statsQuery.isFetching ||
|
||||
alertsQuery.isFetching ||
|
||||
equiposQuery.isFetching ||
|
||||
redQuery.isFetching ||
|
||||
celularesQuery.isFetching
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([
|
||||
statsQuery.refetch(),
|
||||
alertsQuery.refetch(),
|
||||
equiposQuery.refetch(),
|
||||
redQuery.refetch(),
|
||||
celularesQuery.refetch(),
|
||||
])
|
||||
const stats = statsQuery.data ?? {
|
||||
totalDispositivos: 0,
|
||||
dispositivosOnline: 0,
|
||||
dispositivosOffline: 0,
|
||||
dispositivosAlerta: 0,
|
||||
alertasActivas: 0,
|
||||
alertasCriticas: 0,
|
||||
sesionesActivas: 0,
|
||||
}
|
||||
|
||||
const handleDeviceAction = (deviceId: string, action: string) => {
|
||||
console.log(`Action ${action} on device ${deviceId}`)
|
||||
// TODO: Implementar acciones
|
||||
const deviceStatusBreakdown = {
|
||||
online: stats.dispositivosOnline,
|
||||
offline: stats.dispositivosOffline,
|
||||
advertencia: stats.dispositivosAlerta,
|
||||
}
|
||||
|
||||
const handleAcknowledgeAlert = (alertId: string) => {
|
||||
acknowledgeMutation.mutate({ id: alertId })
|
||||
}
|
||||
const allDevices = [
|
||||
...(equiposQuery.data?.dispositivos ?? []),
|
||||
...(redQuery.data?.dispositivos ?? []),
|
||||
...(celularesQuery.data?.dispositivos ?? []),
|
||||
].slice(0, DEVICES_LIMIT)
|
||||
|
||||
const handleResolveAlert = (alertId: string) => {
|
||||
resolveMutation.mutate({ id: alertId })
|
||||
}
|
||||
const quickConnections: QuickConnectionItem[] =
|
||||
allDevices.length > 0
|
||||
? allDevices.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.nombre,
|
||||
status: mapEstadoToQuickStatus(d.estado),
|
||||
}))
|
||||
: MOCK_QUICK_CONNECTIONS
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-gray-500">Vision general del sistema</p>
|
||||
<h1 className="text-3xl font-bold text-white">MSP-CAS Dashboard</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
MeshCentral + LibreNMS + Headwind MDM unificados
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="btn btn-secondary"
|
||||
disabled={isRefreshing}
|
||||
<div className="flex shrink-0">
|
||||
<Link
|
||||
href="/devices"
|
||||
className="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
|
||||
Actualizar
|
||||
</button>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Dispositivo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<KPICards stats={stats} />
|
||||
<section
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
aria-label="Resumen"
|
||||
>
|
||||
<SummaryCard
|
||||
title="Total Dispositivos"
|
||||
value={stats.totalDispositivos}
|
||||
secondary={MOCK_DASHBOARD_SECONDARY.total}
|
||||
icon={<Monitor className="w-6 h-6" />}
|
||||
iconBgClass="bg-primary-900/30"
|
||||
iconColorClass="text-primary-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="En Línea"
|
||||
value={stats.dispositivosOnline}
|
||||
secondary={
|
||||
stats.totalDispositivos > 0
|
||||
? `${Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)}% disponibilidad`
|
||||
: MOCK_DASHBOARD_SECONDARY.online
|
||||
}
|
||||
icon={<CheckCircle className="w-6 h-6" />}
|
||||
iconBgClass="bg-success/20"
|
||||
iconColorClass="text-success"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Fuera de Línea"
|
||||
value={stats.dispositivosOffline}
|
||||
secondary={MOCK_DASHBOARD_SECONDARY.offline}
|
||||
icon={<XCircle className="w-6 h-6" />}
|
||||
iconBgClass="bg-gray-500/20"
|
||||
iconColorClass="text-gray-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Advertencias"
|
||||
value={stats.dispositivosAlerta}
|
||||
secondary={MOCK_DASHBOARD_SECONDARY.alerta}
|
||||
icon={<AlertTriangle className="w-6 h-6" />}
|
||||
iconBgClass="bg-warning/20"
|
||||
iconColorClass="text-warning"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Devices */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Dispositivos</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
Filtrar
|
||||
</button>
|
||||
<div className="flex border border-dark-100 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
viewMode === 'grid' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
viewMode === 'list' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{devicesLoading ? (
|
||||
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
|
||||
Cargando dispositivos...
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
|
||||
No hay dispositivos. Agregue clientes y sincronice con MeshCentral, LibreNMS o Headwind.
|
||||
</div>
|
||||
) : (
|
||||
<DeviceGrid
|
||||
devices={devices}
|
||||
viewMode={viewMode}
|
||||
onAction={handleDeviceAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
<div>
|
||||
{alertsQuery.isLoading ? (
|
||||
<div className="card p-8 text-center text-gray-400">
|
||||
Cargando alertas...
|
||||
</div>
|
||||
) : (
|
||||
<AlertsFeed
|
||||
alerts={alerts}
|
||||
onAcknowledge={handleAcknowledgeAlert}
|
||||
onResolve={handleResolveAlert}
|
||||
maxItems={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<section className="lg:col-span-2">
|
||||
<DeviceStatusChart data={deviceStatusBreakdown} />
|
||||
</section>
|
||||
<section className="min-h-0">
|
||||
<RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-gray-200 mb-4">Salud del Sistema</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.cpu} />
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.ram} />
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.network} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-gray-200 mb-4">Conexión Rápida</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{quickConnections.map((item) => (
|
||||
<QuickConnectionCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(id) => {
|
||||
// TODO: router.push(`/devices?id=${id}`) or open device detail modal
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus {
|
||||
const u = estado?.toUpperCase()
|
||||
if (u === 'ONLINE') return 'online'
|
||||
if (u === 'ALERTA') return 'advertencia'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
88
src/components/dashboard/DeviceStatusChart.tsx
Normal file
88
src/components/dashboard/DeviceStatusChart.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
import type { DeviceStatusBreakdown } from '@/mocks/dashboardData'
|
||||
|
||||
interface DeviceStatusChartProps {
|
||||
data: DeviceStatusBreakdown
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
online: '#22c55e',
|
||||
offline: '#64748b',
|
||||
advertencia: '#eab308',
|
||||
}
|
||||
|
||||
export default function DeviceStatusChart({ data }: DeviceStatusChartProps) {
|
||||
const total = data.online + data.offline + data.advertencia
|
||||
const segments = [
|
||||
{ name: 'En Línea', value: data.online, color: COLORS.online },
|
||||
{ name: 'Fuera de Línea', value: data.offline, color: COLORS.offline },
|
||||
{ name: 'Advertencia', value: data.advertencia, color: COLORS.advertencia },
|
||||
].filter((s) => s.value > 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
|
||||
<div className="flex h-48 items-center justify-center text-gray-500 text-sm">
|
||||
Sin datos
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full max-w-[200px] h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={segments}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={56}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{segments.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value} (${total > 0 ? Math.round((value / total) * 100) : 0}%)`,
|
||||
'',
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2 w-full sm:w-auto">
|
||||
{segments.map((s) => (
|
||||
<li key={s.name} className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="text-gray-400 tabular-nums">
|
||||
{s.value} ({total > 0 ? Math.round((s.value / total) * 100) : 0}%)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/dashboard/QuickConnectionCard.tsx
Normal file
38
src/components/dashboard/QuickConnectionCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QuickConnectionItem, QuickConnectionStatus } from '@/mocks/dashboardData'
|
||||
|
||||
interface QuickConnectionCardProps {
|
||||
item: QuickConnectionItem
|
||||
onClick?: (id: string) => void
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
QuickConnectionStatus,
|
||||
{ dot: string; label: string; text: string }
|
||||
> = {
|
||||
online: { dot: 'bg-success', label: 'En línea', text: 'text-success' },
|
||||
advertencia: { dot: 'bg-warning', label: 'Advertencia', text: 'text-warning' },
|
||||
offline: { dot: 'bg-gray-500', label: 'Fuera de línea', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
export default function QuickConnectionCard({ item, onClick }: QuickConnectionCardProps) {
|
||||
const config = statusConfig[item.status]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(item.id)}
|
||||
className={cn(
|
||||
'card p-4 text-left transition-all hover:scale-[1.02] hover:border-primary-500/50',
|
||||
'flex items-center justify-between gap-3'
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-gray-200 truncate">{item.name}</span>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
|
||||
{config.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
92
src/components/dashboard/RecentActivityList.tsx
Normal file
92
src/components/dashboard/RecentActivityList.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
LogIn,
|
||||
AlertTriangle,
|
||||
Link2,
|
||||
Link2Off,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import type { RecentActivityItem, RecentActivityType } from '@/mocks/dashboardData'
|
||||
import type { AlertSeverity } from '@/mocks/dashboardData'
|
||||
|
||||
function formatRelative(date: Date): string {
|
||||
const s = formatRelativeTime(date)
|
||||
return s === 'ahora' ? 'Hace un momento' : s.replace(/^hace/, 'Hace')
|
||||
}
|
||||
|
||||
interface RecentActivityListProps {
|
||||
items: RecentActivityItem[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const severityIconBg: Record<AlertSeverity, string> = {
|
||||
CRITICAL: 'bg-danger/20 text-danger',
|
||||
WARNING: 'bg-warning/20 text-warning',
|
||||
INFO: 'bg-info/20 text-info',
|
||||
}
|
||||
|
||||
const typeConfig: Record<
|
||||
RecentActivityType,
|
||||
{ icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
login: { icon: <LogIn className="w-4 h-4" />, label: 'Login' },
|
||||
alert: { icon: <AlertTriangle className="w-4 h-4" />, label: 'Alerta' },
|
||||
connection: { icon: <Link2 className="w-4 h-4" />, label: 'Conexión' },
|
||||
disconnection: { icon: <Link2Off className="w-4 h-4" />, label: 'Desconexión' },
|
||||
}
|
||||
|
||||
export default function RecentActivityList({ items, isLoading }: RecentActivityListProps) {
|
||||
return (
|
||||
<div className="card overflow-hidden flex flex-col h-full min-h-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-200">Actividad Reciente</h3>
|
||||
<Link href="/alerts" className="text-sm text-primary-500 hover:underline">
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 divide-y divide-dark-100 max-h-[320px]">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
Cargando...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
No hay alertas recientes
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const config = typeConfig[item.type]
|
||||
const iconBg =
|
||||
item.type === 'alert' && item.severity
|
||||
? severityIconBg[item.severity]
|
||||
: 'bg-dark-300 text-gray-400'
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 p-4 hover:bg-dark-300/30 transition-colors"
|
||||
>
|
||||
<div className={cn('p-2 rounded-lg shrink-0', iconBg)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-gray-200">
|
||||
{item.description}
|
||||
{' · '}
|
||||
<span className="font-medium text-primary-400">
|
||||
{item.deviceName}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatRelative(item.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/dashboard/SummaryCard.tsx
Normal file
38
src/components/dashboard/SummaryCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
value: number
|
||||
secondary?: string
|
||||
icon: React.ReactNode
|
||||
iconBgClass?: string
|
||||
iconColorClass?: string
|
||||
}
|
||||
|
||||
export default function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
secondary,
|
||||
icon,
|
||||
iconBgClass = 'bg-dark-300',
|
||||
iconColorClass = 'text-primary-400',
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className="card p-4 transition-all hover:scale-[1.02]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{title}</p>
|
||||
<p className="text-3xl font-bold mt-1">{value}</p>
|
||||
{secondary && (
|
||||
<p className="text-xs text-gray-500 mt-1">{secondary}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-3 rounded-lg', iconBgClass)}>
|
||||
<span className={iconColorClass}>{icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/dashboard/SystemHealthCard.tsx
Normal file
37
src/components/dashboard/SystemHealthCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SystemHealthMetric } from '@/mocks/dashboardData'
|
||||
|
||||
interface SystemHealthCardProps {
|
||||
metric: SystemHealthMetric
|
||||
}
|
||||
|
||||
const statusBarClass = {
|
||||
healthy: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
critical: 'bg-danger',
|
||||
}
|
||||
|
||||
export default function SystemHealthCard({ metric }: SystemHealthCardProps) {
|
||||
const percent = metric.unit === '%' ? metric.value : Math.min(100, (metric.value / 200) * 100)
|
||||
const barClass = statusBarClass[metric.status]
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">{metric.label}</span>
|
||||
<span className="text-lg font-semibold tabular-nums text-gray-200">
|
||||
{metric.value}
|
||||
{metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-dark-300 overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', barClass)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
src/mocks/dashboardData.ts
Normal file
83
src/mocks/dashboardData.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface DashboardStats {
|
||||
totalDispositivos: number
|
||||
dispositivosOnline: number
|
||||
dispositivosOffline: number
|
||||
dispositivosAlerta: number
|
||||
secondary?: {
|
||||
total?: string
|
||||
online?: string
|
||||
offline?: string
|
||||
alerta?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceStatusBreakdown {
|
||||
online: number
|
||||
offline: number
|
||||
advertencia: number
|
||||
}
|
||||
|
||||
export type RecentActivityType = 'login' | 'alert' | 'connection' | 'disconnection'
|
||||
|
||||
export type AlertSeverity = 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
|
||||
export interface RecentActivityItem {
|
||||
id: string
|
||||
type: RecentActivityType
|
||||
description: string
|
||||
deviceName: string
|
||||
timestamp: Date
|
||||
severity?: AlertSeverity
|
||||
}
|
||||
|
||||
export interface SystemHealthMetric {
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
status: 'healthy' | 'warning' | 'critical'
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
cpu: SystemHealthMetric
|
||||
ram: SystemHealthMetric
|
||||
network: SystemHealthMetric
|
||||
}
|
||||
|
||||
export type QuickConnectionStatus = 'online' | 'advertencia' | 'offline'
|
||||
|
||||
export interface QuickConnectionItem {
|
||||
id: string
|
||||
name: string
|
||||
status: QuickConnectionStatus
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_SECONDARY = {
|
||||
total: '+2 este mes',
|
||||
online: '60% disponibilidad',
|
||||
offline: '-1 vs ayer',
|
||||
alerta: '2 requieren atención',
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_ACTIVITY: RecentActivityItem[] = [
|
||||
{ id: '1', type: 'login', description: 'Sesión iniciada', deviceName: 'PC-ADMIN-01', timestamp: new Date(Date.now() - 1000 * 60 * 2) },
|
||||
{ id: '2', type: 'alert', description: 'CPU alta detectada', deviceName: 'LAPTOP-VENTAS', timestamp: new Date(Date.now() - 1000 * 60 * 15) },
|
||||
{ id: '3', type: 'connection', description: 'Dispositivo conectado', deviceName: 'SRV-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 32) },
|
||||
{ id: '4', type: 'disconnection', description: 'Conexión perdida', deviceName: 'SRV-BACKUP', timestamp: new Date(Date.now() - 1000 * 60 * 120) },
|
||||
{ id: '5', type: 'alert', description: 'Actualización pendiente', deviceName: 'PC-OFICINA-02', timestamp: new Date(Date.now() - 1000 * 60 * 45) },
|
||||
{ id: '6', type: 'connection', description: 'Dispositivo conectado', deviceName: 'ROUTER-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 90) },
|
||||
]
|
||||
|
||||
export const MOCK_SYSTEM_HEALTH: SystemHealth = {
|
||||
cpu: { label: 'CPU Promedio', value: 42, unit: '%', status: 'healthy' },
|
||||
ram: { label: 'RAM Promedio', value: 68, unit: '%', status: 'warning' },
|
||||
network: { label: 'Red', value: 125, unit: 'MB/s', status: 'healthy' },
|
||||
}
|
||||
|
||||
export const MOCK_QUICK_CONNECTIONS: QuickConnectionItem[] = [
|
||||
{ id: '1', name: 'SRV-PRINCIPAL', status: 'online' },
|
||||
{ id: '2', name: 'PC-ADMIN-01', status: 'online' },
|
||||
{ id: '3', name: 'LAPTOP-VENTAS', status: 'advertencia' },
|
||||
{ id: '4', name: 'ROUTER-PRINCIPAL', status: 'online' },
|
||||
{ id: '5', name: 'SW-CORE-01', status: 'online' },
|
||||
{ id: '6', name: 'SRV-BACKUP', status: 'offline' },
|
||||
]
|
||||
Reference in New Issue
Block a user