Dashboard design improved

This commit is contained in:
2026-02-18 10:01:24 -06:00
parent 4235f640d9
commit bd9bffb57c
7 changed files with 526 additions and 323 deletions

View File

@@ -1,178 +1,29 @@
'use client' 'use client'
import { useState, useMemo } from 'react' import { useMemo } from 'react'
import { RefreshCw, Grid, List, Filter } from 'lucide-react' import Link from 'next/link'
import KPICards from '@/components/dashboard/KPICards' import { Monitor, CheckCircle, XCircle, AlertTriangle, Plus } from 'lucide-react'
import DeviceGrid from '@/components/dashboard/DeviceGrid'
import AlertsFeed from '@/components/dashboard/AlertsFeed'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider' import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { cn } from '@/lib/utils'
import { trpc } from '@/lib/trpc-client' 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 = { const DEVICES_LIMIT = 8
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
export default function DashboardPage() { export default function DashboardPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const utils = trpc.useUtils()
const { selectedClientId } = useSelectedClient() const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined const clienteId = selectedClientId ?? undefined
@@ -180,38 +31,6 @@ export default function DashboardPage() {
{ clienteId }, { clienteId },
{ refetchOnWindowFocus: false } { 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( const equiposQuery = trpc.equipos.list.useQuery(
{ page: 1, limit: DEVICES_LIMIT, clienteId }, { page: 1, limit: DEVICES_LIMIT, clienteId },
@@ -226,147 +45,155 @@ export default function DashboardPage() {
{ refetchOnWindowFocus: false } { refetchOnWindowFocus: false }
) )
const devices: DeviceForGrid[] = useMemo(() => { const alertsQuery = trpc.alertas.list.useQuery(
const eq = equiposQuery.data?.dispositivos ?? [] { page: 1, limit: 15, clienteId },
const rd = redQuery.data?.dispositivos ?? [] { refetchOnWindowFocus: false }
const cel = celularesQuery.data?.dispositivos ?? [] )
const all = [...eq, ...rd, ...cel]
return all.map((d) => ({ const recentActivity: RecentActivityItem[] = useMemo(() => {
id: d.id, const list = alertsQuery.data?.alertas ?? []
nombre: d.nombre, return list.map((a) => ({
tipo: d.tipo, id: a.id,
estado: d.estado, type: 'alert' as const,
ip: d.ip ?? null, description: a.titulo,
sistemaOperativo: d.sistemaOperativo ?? null, deviceName: a.dispositivo?.nombre ?? '—',
lastSeen: d.lastSeen ?? null, timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
cpuUsage: d.cpuUsage ?? null, severity: a.severidad,
ramUsage: d.ramUsage ?? null,
cliente: d.cliente ? { nombre: d.cliente.nombre } : undefined,
})) }))
}, [equiposQuery.data, redQuery.data, celularesQuery.data]) }, [alertsQuery.data])
const devicesLoading = const stats = statsQuery.data ?? {
equiposQuery.isLoading || redQuery.isLoading || celularesQuery.isLoading totalDispositivos: 0,
const isRefreshing = dispositivosOnline: 0,
statsQuery.isFetching || dispositivosOffline: 0,
alertsQuery.isFetching || dispositivosAlerta: 0,
equiposQuery.isFetching || alertasActivas: 0,
redQuery.isFetching || alertasCriticas: 0,
celularesQuery.isFetching sesionesActivas: 0,
const handleRefresh = async () => {
await Promise.all([
statsQuery.refetch(),
alertsQuery.refetch(),
equiposQuery.refetch(),
redQuery.refetch(),
celularesQuery.refetch(),
])
} }
const handleDeviceAction = (deviceId: string, action: string) => { const deviceStatusBreakdown = {
console.log(`Action ${action} on device ${deviceId}`) online: stats.dispositivosOnline,
// TODO: Implementar acciones offline: stats.dispositivosOffline,
advertencia: stats.dispositivosAlerta,
} }
const handleAcknowledgeAlert = (alertId: string) => { const allDevices = [
acknowledgeMutation.mutate({ id: alertId }) ...(equiposQuery.data?.dispositivos ?? []),
} ...(redQuery.data?.dispositivos ?? []),
...(celularesQuery.data?.dispositivos ?? []),
].slice(0, DEVICES_LIMIT)
const handleResolveAlert = (alertId: string) => { const quickConnections: QuickConnectionItem[] =
resolveMutation.mutate({ id: alertId }) allDevices.length > 0
} ? allDevices.map((d) => ({
id: d.id,
name: d.nombre,
status: mapEstadoToQuickStatus(d.estado),
}))
: MOCK_QUICK_CONNECTIONS
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Dashboard</h1> <h1 className="text-3xl font-bold text-white">MSP-CAS Dashboard</h1>
<p className="text-gray-500">Vision general del sistema</p> <p className="mt-1 text-gray-400">
MeshCentral + LibreNMS + Headwind MDM unificados
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0">
<button <Link
onClick={handleRefresh} href="/devices"
className="btn btn-secondary" className="btn btn-primary inline-flex items-center gap-2"
disabled={isRefreshing}
> >
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} /> <Plus className="w-4 h-4" />
Actualizar Agregar Dispositivo
</button> </Link>
</div>
</div> </div>
</header>
{/* KPI Cards */} <section
<KPICards stats={stats} /> 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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Devices */} <section className="lg:col-span-2">
<div className="lg:col-span-2 space-y-4"> <DeviceStatusChart data={deviceStatusBreakdown} />
<div className="flex items-center justify-between"> </section>
<h2 className="text-lg font-medium">Dispositivos</h2> <section className="min-h-0">
<div className="flex items-center gap-2"> <RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
<button className="btn btn-ghost btn-sm"> </section>
<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> </div>
{devicesLoading ? ( <section>
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400"> <h2 className="text-lg font-medium text-gray-200 mb-4">Salud del Sistema</h2>
Cargando dispositivos... <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
</div> <SystemHealthCard metric={MOCK_SYSTEM_HEALTH.cpu} />
) : devices.length === 0 ? ( <SystemHealthCard metric={MOCK_SYSTEM_HEALTH.ram} />
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400"> <SystemHealthCard metric={MOCK_SYSTEM_HEALTH.network} />
No hay dispositivos. Agregue clientes y sincronice con MeshCentral, LibreNMS o Headwind.
</div>
) : (
<DeviceGrid
devices={devices}
viewMode={viewMode}
onAction={handleDeviceAction}
/>
)}
</div> </div>
</section>
{/* Alerts */} <section>
<div> <h2 className="text-lg font-medium text-gray-200 mb-4">Conexión Rápida</h2>
{alertsQuery.isLoading ? ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-8 text-center text-gray-400"> {quickConnections.map((item) => (
Cargando alertas... <QuickConnectionCard
</div> key={item.id}
) : ( item={item}
<AlertsFeed onClick={(id) => {
alerts={alerts} // TODO: router.push(`/devices?id=${id}`) or open device detail modal
onAcknowledge={handleAcknowledgeAlert} }}
onResolve={handleResolveAlert}
maxItems={10}
/> />
)} ))}
</div>
</div> </div>
</section>
</div> </div>
) )
} }
function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus {
const u = estado?.toUpperCase()
if (u === 'ONLINE') return 'online'
if (u === 'ALERTA') return 'advertencia'
return 'offline'
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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' },
]