diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 12e6310..106775a 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -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 (
- {/* Header */} -
+
-

Dashboard

-

Vision general del sistema

+

MSP-CAS Dashboard

+

+ MeshCentral + LibreNMS + Headwind MDM unificados +

-
- + + Agregar Dispositivo +
-
+ - {/* KPI Cards */} - +
+ } + iconBgClass="bg-primary-900/30" + iconColorClass="text-primary-400" + /> + 0 + ? `${Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)}% disponibilidad` + : MOCK_DASHBOARD_SECONDARY.online + } + icon={} + iconBgClass="bg-success/20" + iconColorClass="text-success" + /> + } + iconBgClass="bg-gray-500/20" + iconColorClass="text-gray-400" + /> + } + iconBgClass="bg-warning/20" + iconColorClass="text-warning" + /> +
- {/* Main content */}
- {/* Devices */} -
-
-

Dispositivos

-
- -
- - -
-
-
- - {devicesLoading ? ( -
- Cargando dispositivos... -
- ) : devices.length === 0 ? ( -
- No hay dispositivos. Agregue clientes y sincronice con MeshCentral, LibreNMS o Headwind. -
- ) : ( - - )} -
- - {/* Alerts */} -
- {alertsQuery.isLoading ? ( -
- Cargando alertas... -
- ) : ( - - )} -
+
+ +
+
+ +
+ +
+

Salud del Sistema

+
+ + + +
+
+ +
+

Conexión Rápida

+
+ {quickConnections.map((item) => ( + { + // TODO: router.push(`/devices?id=${id}`) or open device detail modal + }} + /> + ))} +
+
) } + +function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus { + const u = estado?.toUpperCase() + if (u === 'ONLINE') return 'online' + if (u === 'ALERTA') return 'advertencia' + return 'offline' +} diff --git a/src/components/dashboard/DeviceStatusChart.tsx b/src/components/dashboard/DeviceStatusChart.tsx new file mode 100644 index 0000000..7e53280 --- /dev/null +++ b/src/components/dashboard/DeviceStatusChart.tsx @@ -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 ( +
+

Estado de Dispositivos

+
+ Sin datos +
+
+ ) + } + + return ( +
+

Estado de Dispositivos

+
+
+ + + + {segments.map((entry, index) => ( + + ))} + + [ + `${value} (${total > 0 ? Math.round((value / total) * 100) : 0}%)`, + '', + ]} + /> + + +
+ +
+
+ ) +} diff --git a/src/components/dashboard/QuickConnectionCard.tsx b/src/components/dashboard/QuickConnectionCard.tsx new file mode 100644 index 0000000..92f09b8 --- /dev/null +++ b/src/components/dashboard/QuickConnectionCard.tsx @@ -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 ( + + ) +} diff --git a/src/components/dashboard/RecentActivityList.tsx b/src/components/dashboard/RecentActivityList.tsx new file mode 100644 index 0000000..da5d0e0 --- /dev/null +++ b/src/components/dashboard/RecentActivityList.tsx @@ -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 = { + 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: , label: 'Login' }, + alert: { icon: , label: 'Alerta' }, + connection: { icon: , label: 'Conexión' }, + disconnection: { icon: , label: 'Desconexión' }, +} + +export default function RecentActivityList({ items, isLoading }: RecentActivityListProps) { + return ( +
+
+

Actividad Reciente

+ + Ver todas + +
+
+ {isLoading ? ( +
+ Cargando... +
+ ) : items.length === 0 ? ( +
+ No hay alertas recientes +
+ ) : ( + 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 ( +
+
+ {config.icon} +
+
+

+ {item.description} + {' · '} + + {item.deviceName} + +

+

+ {formatRelative(item.timestamp)} +

+
+
+ ) + }) + )} +
+
+ ) +} diff --git a/src/components/dashboard/SummaryCard.tsx b/src/components/dashboard/SummaryCard.tsx new file mode 100644 index 0000000..d7c2bb5 --- /dev/null +++ b/src/components/dashboard/SummaryCard.tsx @@ -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 ( +
+
+
+

{title}

+

{value}

+ {secondary && ( +

{secondary}

+ )} +
+
+ {icon} +
+
+
+ ) +} diff --git a/src/components/dashboard/SystemHealthCard.tsx b/src/components/dashboard/SystemHealthCard.tsx new file mode 100644 index 0000000..4f851ea --- /dev/null +++ b/src/components/dashboard/SystemHealthCard.tsx @@ -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 ( +
+
+ {metric.label} + + {metric.value} + {metric.unit} + +
+
+
+
+
+ ) +} diff --git a/src/mocks/dashboardData.ts b/src/mocks/dashboardData.ts new file mode 100644 index 0000000..5879222 --- /dev/null +++ b/src/mocks/dashboardData.ts @@ -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' }, +]