Initial commit: MSP Monitor Dashboard

- Next.js 14 frontend with dark cyan/navy theme
- tRPC API with Prisma ORM
- MeshCentral, LibreNMS, Headwind MDM integrations
- Multi-tenant architecture
- Alert system with email/SMS/webhook notifications
- Docker Compose deployment
- Complete documentation
This commit is contained in:
MSP Monitor
2026-01-21 19:29:20 +00:00
commit f4491757d9
57 changed files with 10503 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
'use client'
import { useState, useEffect } from 'react'
import Sidebar from '@/components/layout/Sidebar'
import Header from '@/components/layout/Header'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const [alertasActivas, setAlertasActivas] = useState(0)
const [user, setUser] = useState({
nombre: 'Admin',
email: 'admin@example.com',
rol: 'SUPER_ADMIN',
})
useEffect(() => {
// TODO: Cargar alertas activas desde API
// TODO: Cargar usuario desde sesion
}, [])
const handleLogout = async () => {
// TODO: Implementar logout
window.location.href = '/login'
}
return (
<div className="flex h-screen bg-dark-500">
<Sidebar alertasActivas={alertasActivas} />
<div className="flex-1 flex flex-col overflow-hidden">
<Header user={user} onLogout={handleLogout} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
import { useState, useEffect } 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 { cn } from '@/lib/utils'
// 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' },
},
]
export default function DashboardPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [isRefreshing, setIsRefreshing] = useState(false)
const [stats, setStats] = useState(mockStats)
const [devices, setDevices] = useState(mockDevices)
const [alerts, setAlerts] = useState(mockAlerts)
const handleRefresh = async () => {
setIsRefreshing(true)
// TODO: Recargar datos de la API
await new Promise((resolve) => setTimeout(resolve, 1000))
setIsRefreshing(false)
}
const handleDeviceAction = (deviceId: string, action: string) => {
console.log(`Action ${action} on device ${deviceId}`)
// TODO: Implementar acciones
}
const handleAcknowledgeAlert = (alertId: string) => {
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RECONOCIDA' as const } : a))
)
// TODO: Llamar API
}
const handleResolveAlert = (alertId: string) => {
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RESUELTA' as const } : a))
)
// TODO: Llamar API
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-500">Vision general del sistema</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="btn btn-secondary"
disabled={isRefreshing}
>
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
Actualizar
</button>
</div>
</div>
{/* KPI Cards */}
<KPICards stats={stats} />
{/* 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>
<DeviceGrid
devices={devices}
viewMode={viewMode}
onAction={handleDeviceAction}
/>
</div>
{/* Alerts */}
<div>
<AlertsFeed
alerts={alerts}
onAcknowledge={handleAcknowledgeAlert}
onResolve={handleResolveAlert}
/>
</div>
</div>
</div>
)
}

249
src/app/globals.css Normal file
View File

@@ -0,0 +1,249 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0d1321;
--foreground: #f1f5f9;
--primary: #06b6d4;
--primary-hover: #0891b2;
--card: #1a2234;
--card-hover: #1e293b;
--border: #334155;
--muted: #64748b;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--primary) var(--card);
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: var(--card);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--primary-hover);
}
body {
background-color: var(--background);
color: var(--foreground);
}
@layer base {
body {
@apply bg-dark-500 text-gray-100 antialiased;
}
}
@layer components {
.card {
@apply bg-dark-200 border border-dark-100 rounded-lg shadow-lg;
}
.card-header {
@apply px-4 py-3 border-b border-dark-100;
}
.card-body {
@apply p-4;
}
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-400 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500;
}
.btn-secondary {
@apply bg-dark-100 hover:bg-dark-300 text-gray-200 border border-dark-100 focus:ring-gray-500;
}
.btn-danger {
@apply bg-danger hover:bg-red-600 text-white focus:ring-red-500;
}
.btn-ghost {
@apply bg-transparent hover:bg-dark-100 text-gray-300 focus:ring-gray-500;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-lg {
@apply px-6 py-3 text-lg;
}
.input {
@apply w-full px-3 py-2 bg-dark-300 border border-dark-100 rounded-lg text-gray-100 placeholder-gray-500 focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 transition-colors;
}
.input-error {
@apply border-danger focus:border-danger focus:ring-danger;
}
.label {
@apply block text-sm font-medium text-gray-300 mb-1;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-success/20 text-success;
}
.badge-warning {
@apply bg-warning/20 text-warning;
}
.badge-danger {
@apply bg-danger/20 text-danger;
}
.badge-info {
@apply bg-info/20 text-info;
}
.badge-gray {
@apply bg-gray-500/20 text-gray-400;
}
.table {
@apply w-full text-left;
}
.table thead {
@apply bg-dark-300 text-gray-400 text-sm uppercase;
}
.table th {
@apply px-4 py-3 font-medium;
}
.table tbody tr {
@apply border-b border-dark-100 hover:bg-dark-300/50 transition-colors;
}
.table td {
@apply px-4 py-3;
}
.sidebar-link {
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-100 transition-colors;
}
.sidebar-link.active {
@apply bg-primary-900/50 text-primary-400 border-l-2 border-primary-500;
}
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-dot-online {
@apply bg-success animate-pulse;
}
.status-dot-offline {
@apply bg-gray-500;
}
.status-dot-alert {
@apply bg-danger animate-pulse;
}
.status-dot-maintenance {
@apply bg-warning;
}
.glow-border {
@apply border border-primary-500/50 shadow-glow;
}
.chart-tooltip {
@apply bg-dark-200 border border-dark-100 rounded-lg p-2 shadow-lg text-sm;
}
.dropdown {
@apply absolute right-0 mt-2 w-48 bg-dark-200 border border-dark-100 rounded-lg shadow-lg py-1 z-50;
}
.dropdown-item {
@apply block px-4 py-2 text-sm text-gray-300 hover:bg-dark-100 hover:text-white transition-colors;
}
.modal-overlay {
@apply fixed inset-0 bg-black/60 backdrop-blur-sm z-40;
}
.modal {
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
}
.modal-content {
@apply bg-dark-200 border border-dark-100 rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto;
}
.modal-header {
@apply flex items-center justify-between px-6 py-4 border-b border-dark-100;
}
.modal-body {
@apply px-6 py-4;
}
.modal-footer {
@apply flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-100;
}
.skeleton {
@apply bg-dark-100 animate-pulse rounded;
}
.tooltip {
@apply absolute z-50 px-2 py-1 text-xs bg-dark-100 border border-dark-300 rounded shadow-lg whitespace-nowrap;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.glow-sm {
box-shadow: 0 0 10px rgba(6, 182, 212, 0.2);
}
.glow-md {
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
}
.glow-lg {
box-shadow: 0 0 40px rgba(6, 182, 212, 0.4);
}
.gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary-400 to-primary-600;
}
.gradient-border {
border-image: linear-gradient(to right, #06b6d4, #0891b2) 1;
}
}

27
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'MSP Monitor - Dashboard',
description: 'Dashboard de monitoreo para MSP - MeshCentral, LibreNMS, Headwind MDM',
icons: {
icon: '/favicon.ico',
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es" className="dark">
<body className={`${inter.className} dark`}>
{children}
</body>
</html>
)
}

View File

@@ -0,0 +1,162 @@
'use client'
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
import { cn, formatRelativeTime } from '@/lib/utils'
interface Alert {
id: string
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
titulo: string
mensaje: string
createdAt: Date
dispositivo?: { nombre: string } | null
cliente: { nombre: string }
}
interface AlertsFeedProps {
alerts: Alert[]
onAcknowledge?: (alertId: string) => void
onResolve?: (alertId: string) => void
maxItems?: number
}
export default function AlertsFeed({
alerts,
onAcknowledge,
onResolve,
maxItems = 10,
}: AlertsFeedProps) {
const displayAlerts = alerts.slice(0, maxItems)
if (displayAlerts.length === 0) {
return (
<div className="card p-8 text-center">
<CheckCircle className="w-12 h-12 text-success mx-auto mb-3" />
<p className="text-gray-400">No hay alertas activas</p>
</div>
)
}
return (
<div className="card overflow-hidden">
<div className="card-header flex items-center justify-between">
<h3 className="font-medium">Alertas Recientes</h3>
<a href="/alertas" className="text-sm text-primary-500 hover:underline">
Ver todas
</a>
</div>
<div className="divide-y divide-dark-100">
{displayAlerts.map((alert) => (
<AlertItem
key={alert.id}
alert={alert}
onAcknowledge={onAcknowledge}
onResolve={onResolve}
/>
))}
</div>
</div>
)
}
function AlertItem({
alert,
onAcknowledge,
onResolve,
}: {
alert: Alert
onAcknowledge?: (alertId: string) => void
onResolve?: (alertId: string) => void
}) {
const severityConfig = {
CRITICAL: {
icon: <AlertTriangle className="w-5 h-5" />,
color: 'text-danger',
bgColor: 'bg-danger/20',
borderColor: 'border-l-danger',
},
WARNING: {
icon: <AlertTriangle className="w-5 h-5" />,
color: 'text-warning',
bgColor: 'bg-warning/20',
borderColor: 'border-l-warning',
},
INFO: {
icon: <Info className="w-5 h-5" />,
color: 'text-info',
bgColor: 'bg-info/20',
borderColor: 'border-l-info',
},
}
const config = severityConfig[alert.severidad]
return (
<div
className={cn(
'p-4 border-l-4 hover:bg-dark-300/30 transition-colors',
config.borderColor,
alert.severidad === 'CRITICAL' && 'animate-pulse-slow'
)}
>
<div className="flex items-start gap-3">
<div className={cn('p-2 rounded-lg', config.bgColor)}>
<span className={config.color}>{config.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="font-medium text-sm">{alert.titulo}</h4>
<p className="text-xs text-gray-400 mt-0.5">{alert.mensaje}</p>
</div>
<span
className={cn(
'badge shrink-0',
alert.estado === 'ACTIVA' && 'badge-danger',
alert.estado === 'RECONOCIDA' && 'badge-warning',
alert.estado === 'RESUELTA' && 'badge-success'
)}
>
{alert.estado}
</span>
</div>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-1 text-xs text-gray-500">
<Clock className="w-3 h-3" />
{formatRelativeTime(alert.createdAt)}
</div>
{alert.dispositivo && (
<span className="text-xs text-gray-500">
{alert.dispositivo.nombre}
</span>
)}
<span className="text-xs text-gray-600">
{alert.cliente.nombre}
</span>
</div>
{alert.estado === 'ACTIVA' && (
<div className="flex gap-2 mt-3">
<button
onClick={() => onAcknowledge?.(alert.id)}
className="btn btn-ghost btn-sm"
>
Reconocer
</button>
<button
onClick={() => onResolve?.(alert.id)}
className="btn btn-ghost btn-sm text-success"
>
Resolver
</button>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,341 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import {
Monitor,
Laptop,
Server,
Smartphone,
Tablet,
Router,
Network,
Shield,
Wifi,
Printer,
HelpCircle,
MoreVertical,
ExternalLink,
Power,
Terminal,
FolderOpen,
} from 'lucide-react'
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor } from '@/lib/utils'
interface Device {
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 }
}
interface DeviceGridProps {
devices: Device[]
viewMode?: 'grid' | 'list'
onAction?: (deviceId: string, action: string) => void
}
const deviceIcons: Record<string, React.ReactNode> = {
PC: <Monitor className="w-8 h-8" />,
LAPTOP: <Laptop className="w-8 h-8" />,
SERVIDOR: <Server className="w-8 h-8" />,
CELULAR: <Smartphone className="w-8 h-8" />,
TABLET: <Tablet className="w-8 h-8" />,
ROUTER: <Router className="w-8 h-8" />,
SWITCH: <Network className="w-8 h-8" />,
FIREWALL: <Shield className="w-8 h-8" />,
AP: <Wifi className="w-8 h-8" />,
IMPRESORA: <Printer className="w-8 h-8" />,
OTRO: <HelpCircle className="w-8 h-8" />,
}
export default function DeviceGrid({ devices, viewMode = 'grid', onAction }: DeviceGridProps) {
if (viewMode === 'list') {
return <DeviceList devices={devices} onAction={onAction} />
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{devices.map((device) => (
<DeviceCard key={device.id} device={device} onAction={onAction} />
))}
</div>
)
}
function DeviceCard({
device,
onAction,
}: {
device: Device
onAction?: (deviceId: string, action: string) => void
}) {
const [showMenu, setShowMenu] = useState(false)
const getDeviceUrl = () => {
const type = device.tipo
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/equipos/${device.id}`
if (['CELULAR', 'TABLET'].includes(type)) return `/celulares/${device.id}`
return `/red/${device.id}`
}
return (
<div
className={cn(
'card p-4 transition-all hover:border-primary-500/50 relative group',
device.estado === 'ALERTA' && 'border-danger/50'
)}
>
{/* Status indicator */}
<div className="absolute top-3 right-3 flex items-center gap-2">
<span
className={cn(
'status-dot',
device.estado === 'ONLINE' && 'status-dot-online',
device.estado === 'OFFLINE' && 'status-dot-offline',
device.estado === 'ALERTA' && 'status-dot-alert',
device.estado === 'MANTENIMIENTO' && 'status-dot-maintenance'
)}
/>
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-dark-100 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical className="w-4 h-4 text-gray-500" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div className="dropdown right-0 z-50">
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
<>
<button
onClick={() => {
onAction?.(device.id, 'desktop')
setShowMenu(false)
}}
className="dropdown-item flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Escritorio remoto
</button>
<button
onClick={() => {
onAction?.(device.id, 'terminal')
setShowMenu(false)
}}
className="dropdown-item flex items-center gap-2"
>
<Terminal className="w-4 h-4" />
Terminal
</button>
<button
onClick={() => {
onAction?.(device.id, 'files')
setShowMenu(false)
}}
className="dropdown-item flex items-center gap-2"
>
<FolderOpen className="w-4 h-4" />
Archivos
</button>
<div className="h-px bg-dark-100 my-1" />
</>
)}
<button
onClick={() => {
onAction?.(device.id, 'restart')
setShowMenu(false)
}}
className="dropdown-item flex items-center gap-2 text-warning"
>
<Power className="w-4 h-4" />
Reiniciar
</button>
</div>
</>
)}
</div>
</div>
{/* Icon and name */}
<Link href={getDeviceUrl()} className="block">
<div className="flex items-center gap-4 mb-3">
<div className={cn('p-3 rounded-lg', getStatusBgColor(device.estado))}>
<span className={getStatusColor(device.estado)}>
{deviceIcons[device.tipo] || deviceIcons.OTRO}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{device.nombre}</h3>
<p className="text-xs text-gray-500">{device.tipo}</p>
</div>
</div>
</Link>
{/* Details */}
<div className="space-y-2 text-sm">
{device.ip && (
<div className="flex justify-between">
<span className="text-gray-500">IP</span>
<span className="font-mono text-gray-300">{device.ip}</span>
</div>
)}
{device.sistemaOperativo && (
<div className="flex justify-between">
<span className="text-gray-500">OS</span>
<span className="text-gray-300 truncate ml-2">{device.sistemaOperativo}</span>
</div>
)}
{device.lastSeen && (
<div className="flex justify-between">
<span className="text-gray-500">Visto</span>
<span className="text-gray-400">{formatRelativeTime(device.lastSeen)}</span>
</div>
)}
</div>
{/* Metrics bar */}
{device.estado === 'ONLINE' && (device.cpuUsage !== null || device.ramUsage !== null) && (
<div className="mt-3 pt-3 border-t border-dark-100 grid grid-cols-2 gap-2">
{device.cpuUsage !== null && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-500">CPU</span>
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
{Math.round(device.cpuUsage)}%
</span>
</div>
<div className="h-1 bg-dark-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
device.cpuUsage > 80 ? 'bg-danger' : device.cpuUsage > 60 ? 'bg-warning' : 'bg-success'
)}
style={{ width: `${device.cpuUsage}%` }}
/>
</div>
</div>
)}
{device.ramUsage !== null && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-500">RAM</span>
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
{Math.round(device.ramUsage)}%
</span>
</div>
<div className="h-1 bg-dark-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
device.ramUsage > 80 ? 'bg-danger' : device.ramUsage > 60 ? 'bg-warning' : 'bg-success'
)}
style={{ width: `${device.ramUsage}%` }}
/>
</div>
</div>
)}
</div>
)}
</div>
)
}
function DeviceList({
devices,
onAction,
}: {
devices: Device[]
onAction?: (deviceId: string, action: string) => void
}) {
return (
<div className="card overflow-hidden">
<table className="table">
<thead>
<tr>
<th>Dispositivo</th>
<th>Tipo</th>
<th>IP</th>
<th>Estado</th>
<th>CPU</th>
<th>RAM</th>
<th>Ultimo contacto</th>
<th></th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr key={device.id}>
<td>
<div className="flex items-center gap-3">
<span className={getStatusColor(device.estado)}>
{deviceIcons[device.tipo] || deviceIcons.OTRO}
</span>
<div>
<div className="font-medium">{device.nombre}</div>
{device.cliente && (
<div className="text-xs text-gray-500">{device.cliente.nombre}</div>
)}
</div>
</div>
</td>
<td className="text-gray-400">{device.tipo}</td>
<td className="font-mono text-gray-400">{device.ip || '-'}</td>
<td>
<span
className={cn(
'badge',
device.estado === 'ONLINE' && 'badge-success',
device.estado === 'OFFLINE' && 'badge-gray',
device.estado === 'ALERTA' && 'badge-danger',
device.estado === 'MANTENIMIENTO' && 'badge-warning'
)}
>
{device.estado}
</span>
</td>
<td>
{device.cpuUsage !== null ? (
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
{Math.round(device.cpuUsage)}%
</span>
) : (
'-'
)}
</td>
<td>
{device.ramUsage !== null ? (
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
{Math.round(device.ramUsage)}%
</span>
) : (
'-'
)}
</td>
<td className="text-gray-500">
{device.lastSeen ? formatRelativeTime(device.lastSeen) : '-'}
</td>
<td>
<Link
href={`/equipos/${device.id}`}
className="btn btn-ghost btn-sm"
>
Ver
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import { Monitor, Smartphone, Network, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface KPICardsProps {
stats: {
totalDispositivos: number
dispositivosOnline: number
dispositivosOffline: number
dispositivosAlerta: number
alertasActivas: number
alertasCriticas: number
}
}
export default function KPICards({ stats }: KPICardsProps) {
const cards = [
{
title: 'Total Dispositivos',
value: stats.totalDispositivos,
icon: <Monitor className="w-6 h-6" />,
color: 'text-primary-400',
bgColor: 'bg-primary-900/30',
},
{
title: 'En Linea',
value: stats.dispositivosOnline,
icon: <CheckCircle className="w-6 h-6" />,
color: 'text-success',
bgColor: 'bg-success/20',
percentage: stats.totalDispositivos > 0
? Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)
: 0,
},
{
title: 'Fuera de Linea',
value: stats.dispositivosOffline,
icon: <XCircle className="w-6 h-6" />,
color: 'text-gray-400',
bgColor: 'bg-gray-500/20',
},
{
title: 'Con Alertas',
value: stats.dispositivosAlerta,
icon: <AlertTriangle className="w-6 h-6" />,
color: 'text-warning',
bgColor: 'bg-warning/20',
},
{
title: 'Alertas Activas',
value: stats.alertasActivas,
icon: <AlertTriangle className="w-6 h-6" />,
color: 'text-danger',
bgColor: 'bg-danger/20',
highlight: stats.alertasCriticas > 0,
subtitle: stats.alertasCriticas > 0
? `${stats.alertasCriticas} criticas`
: undefined,
},
]
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{cards.map((card, index) => (
<div
key={index}
className={cn(
'card p-4 transition-all hover:scale-[1.02]',
card.highlight && 'border-danger glow-border animate-pulse'
)}
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-400">{card.title}</p>
<p className="text-3xl font-bold mt-1">{card.value}</p>
{card.subtitle && (
<p className="text-xs text-danger mt-1">{card.subtitle}</p>
)}
{card.percentage !== undefined && (
<p className="text-xs text-gray-500 mt-1">{card.percentage}% del total</p>
)}
</div>
<div className={cn('p-3 rounded-lg', card.bgColor)}>
<span className={card.color}>{card.icon}</span>
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,135 @@
'use client'
import { useState } from 'react'
import { Building2, ChevronDown, Check, Search } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Client {
id: string
nombre: string
codigo: string
}
interface ClientSelectorProps {
clients?: Client[]
selectedId?: string | null
onChange?: (clientId: string | null) => void
showAll?: boolean
}
export default function ClientSelector({
clients = [],
selectedId = null,
onChange,
showAll = true,
}: ClientSelectorProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const selectedClient = selectedId
? clients.find((c) => c.id === selectedId)
: null
const filteredClients = clients.filter(
(c) =>
c.nombre.toLowerCase().includes(search.toLowerCase()) ||
c.codigo.toLowerCase().includes(search.toLowerCase())
)
const handleSelect = (id: string | null) => {
onChange?.(id)
setOpen(false)
setSearch('')
}
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-3 py-2 bg-dark-300 border border-dark-100 rounded-lg hover:border-primary-500 transition-colors min-w-[200px]"
>
<Building2 className="w-4 h-4 text-gray-500" />
<span className="flex-1 text-left text-sm">
{selectedClient ? selectedClient.nombre : 'Todos los clientes'}
</span>
<ChevronDown
className={cn(
'w-4 h-4 text-gray-500 transition-transform',
open && 'rotate-180'
)}
/>
</button>
{open && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => {
setOpen(false)
setSearch('')
}}
/>
<div className="absolute left-0 mt-2 w-72 bg-dark-200 border border-dark-100 rounded-lg shadow-lg z-50">
{/* Search */}
<div className="p-2 border-b border-dark-100">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar cliente..."
className="input py-1.5 pl-8 text-sm"
autoFocus
/>
</div>
</div>
{/* Options */}
<div className="max-h-60 overflow-y-auto p-1">
{showAll && (
<button
onClick={() => handleSelect(null)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-dark-100 transition-colors',
!selectedId && 'bg-primary-900/50 text-primary-400'
)}
>
<Building2 className="w-4 h-4" />
<span className="flex-1 text-left">Todos los clientes</span>
{!selectedId && <Check className="w-4 h-4" />}
</button>
)}
{filteredClients.length === 0 ? (
<div className="px-3 py-4 text-center text-gray-500 text-sm">
No se encontraron clientes
</div>
) : (
filteredClients.map((client) => (
<button
key={client.id}
onClick={() => handleSelect(client.id)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-dark-100 transition-colors',
selectedId === client.id && 'bg-primary-900/50 text-primary-400'
)}
>
<div className="w-8 h-8 rounded-lg bg-dark-100 flex items-center justify-center text-xs font-medium text-gray-400">
{client.codigo.substring(0, 2).toUpperCase()}
</div>
<div className="flex-1 text-left">
<div className="font-medium">{client.nombre}</div>
<div className="text-xs text-gray-500">{client.codigo}</div>
</div>
{selectedId === client.id && <Check className="w-4 h-4" />}
</button>
))
)}
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { useState } from 'react'
import { Bell, Search, User, LogOut, Settings, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import ClientSelector from './ClientSelector'
interface HeaderProps {
user?: {
nombre: string
email: string
avatar?: string
rol: string
}
onLogout?: () => void
}
export default function Header({ user, onLogout }: HeaderProps) {
const [showUserMenu, setShowUserMenu] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
return (
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between px-6">
{/* Search */}
<div className="flex items-center gap-4 flex-1">
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Buscar dispositivos, clientes..."
className="input pl-10 bg-dark-300"
/>
</div>
{/* Client Selector */}
<ClientSelector />
</div>
{/* Right section */}
<div className="flex items-center gap-4">
{/* Notifications */}
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
>
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-danger rounded-full" />
</button>
{showNotifications && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowNotifications(false)}
/>
<div className="dropdown w-80 right-0 z-50">
<div className="px-4 py-3 border-b border-dark-100">
<h3 className="font-medium">Notificaciones</h3>
</div>
<div className="max-h-96 overflow-y-auto">
<NotificationItem
type="critical"
title="Servidor principal offline"
message="El servidor SRV-01 no responde"
time="hace 5 min"
/>
<NotificationItem
type="warning"
title="CPU alta en PC-ADMIN"
message="Uso de CPU al 95%"
time="hace 15 min"
/>
<NotificationItem
type="info"
title="Backup completado"
message="Backup diario finalizado"
time="hace 1 hora"
/>
</div>
<div className="px-4 py-3 border-t border-dark-100">
<a href="/alertas" className="text-primary-500 text-sm hover:underline">
Ver todas las alertas
</a>
</div>
</div>
</>
)}
</div>
{/* User menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-100 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium">
{user?.avatar ? (
<img src={user.avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
user?.nombre?.charAt(0).toUpperCase() || 'U'
)}
</div>
<div className="text-left hidden sm:block">
<div className="text-sm font-medium text-gray-200">{user?.nombre || 'Usuario'}</div>
<div className="text-xs text-gray-500">{user?.rol || 'Rol'}</div>
</div>
<ChevronDown className="w-4 h-4 text-gray-500" />
</button>
{showUserMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
<div className="dropdown z-50">
<div className="px-4 py-3 border-b border-dark-100">
<div className="text-sm font-medium">{user?.nombre}</div>
<div className="text-xs text-gray-500">{user?.email}</div>
</div>
<a href="/perfil" className="dropdown-item flex items-center gap-2">
<User className="w-4 h-4" />
Mi perfil
</a>
<a href="/configuracion" className="dropdown-item flex items-center gap-2">
<Settings className="w-4 h-4" />
Configuracion
</a>
<div className="h-px bg-dark-100 my-1" />
<button
onClick={onLogout}
className="dropdown-item flex items-center gap-2 w-full text-left text-danger"
>
<LogOut className="w-4 h-4" />
Cerrar sesion
</button>
</div>
</>
)}
</div>
</div>
</header>
)
}
interface NotificationItemProps {
type: 'critical' | 'warning' | 'info'
title: string
message: string
time: string
}
function NotificationItem({ type, title, message, time }: NotificationItemProps) {
const colors = {
critical: 'bg-danger/20 border-danger',
warning: 'bg-warning/20 border-warning',
info: 'bg-info/20 border-info',
}
return (
<div
className={cn(
'px-4 py-3 border-l-4 hover:bg-dark-100 cursor-pointer transition-colors',
colors[type]
)}
>
<div className="font-medium text-sm">{title}</div>
<div className="text-xs text-gray-400">{message}</div>
<div className="text-xs text-gray-500 mt-1">{time}</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
LayoutDashboard,
Monitor,
Smartphone,
Network,
AlertTriangle,
FileText,
Settings,
Users,
Building2,
ChevronLeft,
ChevronRight,
Activity,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavItem {
label: string
href: string
icon: React.ReactNode
badge?: number
}
const navItems: NavItem[] = [
{
label: 'Dashboard',
href: '/',
icon: <LayoutDashboard className="w-5 h-5" />,
},
{
label: 'Equipos',
href: '/equipos',
icon: <Monitor className="w-5 h-5" />,
},
{
label: 'Celulares',
href: '/celulares',
icon: <Smartphone className="w-5 h-5" />,
},
{
label: 'Red',
href: '/red',
icon: <Network className="w-5 h-5" />,
},
{
label: 'Alertas',
href: '/alertas',
icon: <AlertTriangle className="w-5 h-5" />,
},
{
label: 'Reportes',
href: '/reportes',
icon: <FileText className="w-5 h-5" />,
},
]
const adminItems: NavItem[] = [
{
label: 'Clientes',
href: '/clientes',
icon: <Building2 className="w-5 h-5" />,
},
{
label: 'Usuarios',
href: '/usuarios',
icon: <Users className="w-5 h-5" />,
},
{
label: 'Configuracion',
href: '/configuracion',
icon: <Settings className="w-5 h-5" />,
},
]
interface SidebarProps {
alertasActivas?: number
}
export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const pathname = usePathname()
const isActive = (href: string) => {
if (href === '/') return pathname === '/'
return pathname.startsWith(href)
}
const items = navItems.map((item) => ({
...item,
badge: item.href === '/alertas' ? alertasActivas : undefined,
}))
return (
<aside
className={cn(
'h-screen bg-dark-400 border-r border-dark-100 flex flex-col transition-all duration-300',
collapsed ? 'w-16' : 'w-64'
)}
>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-100">
{!collapsed && (
<Link href="/" className="flex items-center gap-2">
<Activity className="w-8 h-8 text-primary-500" />
<span className="font-bold text-lg gradient-text">MSP Monitor</span>
</Link>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1.5 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
>
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'sidebar-link',
isActive(item.href) && 'active',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.label : undefined}
>
{item.icon}
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span className="badge badge-danger">{item.badge}</span>
)}
</>
)}
{collapsed && item.badge !== undefined && item.badge > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger rounded-full text-xs flex items-center justify-center">
{item.badge > 9 ? '9+' : item.badge}
</span>
)}
</Link>
))}
{/* Separador */}
<div className="h-px bg-dark-100 my-4" />
{/* Admin items */}
{adminItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'sidebar-link',
isActive(item.href) && 'active',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.label : undefined}
>
{item.icon}
{!collapsed && <span className="flex-1">{item.label}</span>}
</Link>
))}
</nav>
{/* Footer */}
{!collapsed && (
<div className="p-4 border-t border-dark-100">
<div className="text-xs text-gray-500 text-center">
MSP Monitor v1.0.0
</div>
</div>
)}
</aside>
)
}

110
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,110 @@
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { SessionUser } from '@/types'
import prisma from './prisma'
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'development-secret-key-change-in-production')
const COOKIE_NAME = 'msp-session'
export async function createSession(user: SessionUser): Promise<string> {
const token = await new SignJWT({
id: user.id,
email: user.email,
nombre: user.nombre,
rol: user.rol,
clienteId: user.clienteId,
meshcentralUser: user.meshcentralUser,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('24h')
.sign(JWT_SECRET)
return token
}
export async function verifySession(token: string): Promise<SessionUser | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload as unknown as SessionUser
} catch {
return null
}
}
export async function getSession(): Promise<SessionUser | null> {
const cookieStore = cookies()
const token = cookieStore.get(COOKIE_NAME)?.value
if (!token) return null
return verifySession(token)
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = cookies()
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 horas
path: '/',
})
}
export async function clearSession(): Promise<void> {
const cookieStore = cookies()
cookieStore.delete(COOKIE_NAME)
}
export async function validateMeshCentralUser(username: string, token: string): Promise<SessionUser | null> {
// Verificar con MeshCentral que el token es valido
const meshUrl = process.env.MESHCENTRAL_URL
if (!meshUrl) return null
try {
const response = await fetch(`${meshUrl}/api/users`, {
headers: {
'x-meshauth': token,
},
})
if (!response.ok) return null
// Buscar o crear usuario en nuestra BD
let usuario = await prisma.usuario.findUnique({
where: { meshcentralUser: username },
include: { cliente: true },
})
if (!usuario) {
// Crear usuario si no existe
usuario = await prisma.usuario.create({
data: {
email: `${username}@meshcentral.local`,
nombre: username,
meshcentralUser: username,
rol: 'TECNICO',
},
include: { cliente: true },
})
}
// Actualizar lastLogin
await prisma.usuario.update({
where: { id: usuario.id },
data: { lastLogin: new Date() },
})
return {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
clienteId: usuario.clienteId,
meshcentralUser: usuario.meshcentralUser,
}
} catch (error) {
console.error('Error validating MeshCentral user:', error)
return null
}
}

15
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

38
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,38 @@
import Redis from 'ioredis'
const globalForRedis = globalThis as unknown as {
redis: Redis | undefined
}
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null,
enableReadyCheck: false,
})
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis
export default redis
// Funciones helper para cache
export async function getCache<T>(key: string): Promise<T | null> {
const data = await redis.get(key)
if (!data) return null
return JSON.parse(data) as T
}
export async function setCache<T>(key: string, value: T, ttlSeconds: number = 300): Promise<void> {
await redis.setex(key, ttlSeconds, JSON.stringify(value))
}
export async function deleteCache(key: string): Promise<void> {
await redis.del(key)
}
export async function invalidatePattern(pattern: string): Promise<void> {
const keys = await redis.keys(pattern)
if (keys.length > 0) {
await redis.del(...keys)
}
}

123
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,123 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
export function formatDate(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatRelativeTime(date: Date | string): string {
const d = new Date(date)
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `hace ${days}d`
if (hours > 0) return `hace ${hours}h`
if (minutes > 0) return `hace ${minutes}m`
return 'ahora'
}
export function getStatusColor(status: string): string {
switch (status.toUpperCase()) {
case 'ONLINE':
return 'text-success'
case 'OFFLINE':
return 'text-gray-500'
case 'ALERTA':
return 'text-danger'
case 'MANTENIMIENTO':
return 'text-warning'
default:
return 'text-gray-400'
}
}
export function getStatusBgColor(status: string): string {
switch (status.toUpperCase()) {
case 'ONLINE':
return 'bg-success/20'
case 'OFFLINE':
return 'bg-gray-500/20'
case 'ALERTA':
return 'bg-danger/20'
case 'MANTENIMIENTO':
return 'bg-warning/20'
default:
return 'bg-gray-400/20'
}
}
export function getSeverityColor(severity: string): string {
switch (severity.toUpperCase()) {
case 'CRITICAL':
return 'text-danger'
case 'WARNING':
return 'text-warning'
case 'INFO':
return 'text-info'
default:
return 'text-gray-400'
}
}
export function getSeverityBgColor(severity: string): string {
switch (severity.toUpperCase()) {
case 'CRITICAL':
return 'bg-danger/20'
case 'WARNING':
return 'bg-warning/20'
case 'INFO':
return 'bg-info/20'
default:
return 'bg-gray-400/20'
}
}
export function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 15)
}

View File

@@ -0,0 +1,347 @@
import prisma from '@/lib/prisma'
import { addNotificationJob } from './queue'
export async function processAlert(alertaId: string) {
console.log(`Procesando alerta ${alertaId}`)
const alerta = await prisma.alerta.findUnique({
where: { id: alertaId },
include: {
cliente: true,
dispositivo: true,
regla: true,
},
})
if (!alerta) {
console.error(`Alerta ${alertaId} no encontrada`)
return
}
// Si ya fue notificada, no volver a procesar
if (alerta.notificada) {
return
}
// Obtener usuarios que deben ser notificados
const usuarios = await prisma.usuario.findMany({
where: {
OR: [
{ clienteId: alerta.clienteId },
{ clienteId: null, rol: { in: ['SUPER_ADMIN', 'ADMIN'] } },
],
activo: true,
},
})
const notificaciones: Promise<unknown>[] = []
for (const usuario of usuarios) {
// Verificar si el usuario quiere notificaciones segun severidad
const debeNotificar = shouldNotify(usuario, alerta.severidad)
if (!debeNotificar) continue
// Email
if (usuario.notificarEmail && usuario.email) {
notificaciones.push(
addNotificationJob({
type: 'send-email',
alertaId: alerta.id,
destinatario: usuario.email,
titulo: formatAlertTitle(alerta),
mensaje: formatAlertMessage(alerta),
})
)
}
// SMS
if (usuario.notificarSms && usuario.telefono && alerta.severidad === 'CRITICAL') {
notificaciones.push(
addNotificationJob({
type: 'send-sms',
alertaId: alerta.id,
destinatario: usuario.telefono,
titulo: formatAlertTitle(alerta),
mensaje: formatAlertMessageShort(alerta),
})
)
}
}
// Webhook si la regla lo tiene configurado
if (alerta.regla?.notificarWebhook && alerta.regla.webhookUrl) {
notificaciones.push(
addNotificationJob({
type: 'send-webhook',
alertaId: alerta.id,
destinatario: alerta.regla.webhookUrl,
titulo: formatAlertTitle(alerta),
mensaje: JSON.stringify({
id: alerta.id,
severidad: alerta.severidad,
titulo: alerta.titulo,
mensaje: alerta.mensaje,
dispositivo: alerta.dispositivo?.nombre,
cliente: alerta.cliente.nombre,
timestamp: alerta.createdAt,
}),
webhookUrl: alerta.regla.webhookUrl,
})
)
}
await Promise.all(notificaciones)
// Marcar como notificada
await prisma.alerta.update({
where: { id: alertaId },
data: { notificada: true },
})
console.log(`Alerta ${alertaId} procesada, ${notificaciones.length} notificaciones enviadas`)
}
export async function checkAlertRules() {
console.log('Verificando reglas de alerta...')
// Obtener todas las reglas activas
const reglas = await prisma.alertaRegla.findMany({
where: { activa: true },
})
for (const regla of reglas) {
try {
await evaluateRule(regla)
} catch (error) {
console.error(`Error evaluando regla ${regla.id}:`, error)
}
}
console.log('Verificacion de reglas completada')
}
async function evaluateRule(regla: {
id: string
clienteId: string | null
nombre: string
tipoDispositivo: string | null
metrica: string
operador: string
umbral: number
duracionMinutos: number
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
}) {
// Obtener dispositivos que aplican a esta regla
const where: Record<string, unknown> = {}
if (regla.clienteId) {
where.clienteId = regla.clienteId
}
if (regla.tipoDispositivo) {
where.tipo = regla.tipoDispositivo
}
where.estado = 'ONLINE'
const dispositivos = await prisma.dispositivo.findMany({
where,
select: {
id: true,
nombre: true,
clienteId: true,
cpuUsage: true,
ramUsage: true,
discoUsage: true,
temperatura: true,
bateria: true,
},
})
for (const dispositivo of dispositivos) {
const valor = getMetricValue(dispositivo, regla.metrica)
if (valor === null) continue
const cumpleCondicion = evaluateCondition(valor, regla.operador, regla.umbral)
if (cumpleCondicion) {
// Verificar si la condicion se mantiene por el tiempo requerido
const metricas = await prisma.dispositivoMetrica.findMany({
where: {
dispositivoId: dispositivo.id,
timestamp: {
gte: new Date(Date.now() - regla.duracionMinutos * 60 * 1000),
},
},
orderBy: { timestamp: 'asc' },
})
// Si hay suficientes metricas y todas cumplen la condicion
if (metricas.length >= Math.floor(regla.duracionMinutos / 5)) {
const todasCumplen = metricas.every((m) => {
const v = getMetricValueFromRecord(m, regla.metrica)
return v !== null && evaluateCondition(v, regla.operador, regla.umbral)
})
if (todasCumplen) {
await createAlertFromRule(regla, dispositivo, valor)
}
}
}
}
}
function getMetricValue(
dispositivo: {
cpuUsage: number | null
ramUsage: number | null
discoUsage: number | null
temperatura: number | null
bateria: number | null
},
metrica: string
): number | null {
switch (metrica) {
case 'cpu':
return dispositivo.cpuUsage
case 'ram':
return dispositivo.ramUsage
case 'disco':
return dispositivo.discoUsage
case 'temperatura':
return dispositivo.temperatura
case 'bateria':
return dispositivo.bateria
default:
return null
}
}
function getMetricValueFromRecord(
record: {
cpuUsage: number | null
ramUsage: number | null
discoUsage: number | null
temperatura: number | null
bateria: number | null
},
metrica: string
): number | null {
return getMetricValue(record, metrica)
}
function evaluateCondition(valor: number, operador: string, umbral: number): boolean {
switch (operador) {
case '>':
return valor > umbral
case '<':
return valor < umbral
case '>=':
return valor >= umbral
case '<=':
return valor <= umbral
case '==':
return valor === umbral
default:
return false
}
}
async function createAlertFromRule(
regla: {
id: string
nombre: string
metrica: string
operador: string
umbral: number
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
},
dispositivo: { id: string; nombre: string; clienteId: string },
valor: number
) {
// Verificar si ya existe una alerta activa para esta regla y dispositivo
const existente = await prisma.alerta.findFirst({
where: {
reglaId: regla.id,
dispositivoId: dispositivo.id,
estado: 'ACTIVA',
},
})
if (existente) return
const alerta = await prisma.alerta.create({
data: {
clienteId: dispositivo.clienteId,
dispositivoId: dispositivo.id,
reglaId: regla.id,
severidad: regla.severidad,
titulo: `${regla.nombre}: ${dispositivo.nombre}`,
mensaje: `${regla.metrica} es ${valor} (umbral: ${regla.operador} ${regla.umbral})`,
origen: 'sistema',
},
})
await addNotificationJob({
type: 'send-email',
alertaId: alerta.id,
destinatario: '', // Se determinara en el procesador
titulo: '',
mensaje: '',
})
}
function shouldNotify(
usuario: { rol: string },
severidad: string
): boolean {
// Super admins y admins reciben todas las alertas
if (['SUPER_ADMIN', 'ADMIN'].includes(usuario.rol)) {
return true
}
// Tecnicos reciben warning y critical
if (usuario.rol === 'TECNICO') {
return ['WARNING', 'CRITICAL'].includes(severidad)
}
// Clientes solo reciben critical
if (usuario.rol === 'CLIENTE') {
return severidad === 'CRITICAL'
}
return false
}
function formatAlertTitle(alerta: {
severidad: string
titulo: string
cliente: { nombre: string }
}): string {
const emoji =
alerta.severidad === 'CRITICAL' ? '🔴' : alerta.severidad === 'WARNING' ? '🟡' : '🔵'
return `${emoji} [${alerta.cliente.nombre}] ${alerta.titulo}`
}
function formatAlertMessage(alerta: {
mensaje: string
dispositivo: { nombre: string } | null
createdAt: Date
}): string {
return `
${alerta.mensaje}
Dispositivo: ${alerta.dispositivo?.nombre || 'N/A'}
Fecha: ${alerta.createdAt.toLocaleString('es-MX')}
---
MSP Monitor Dashboard
`.trim()
}
function formatAlertMessageShort(alerta: {
titulo: string
dispositivo: { nombre: string } | null
}): string {
return `${alerta.titulo} - ${alerta.dispositivo?.nombre || 'N/A'}`
}

View File

@@ -0,0 +1,218 @@
import prisma from '@/lib/prisma'
import { MeshCentralClient } from '../services/meshcentral/client'
import { LibreNMSClient } from '../services/librenms/client'
import { HeadwindClient } from '../services/headwind/client'
export async function cleanupMetrics(olderThanDays: number): Promise<void> {
console.log(`Limpiando metricas de mas de ${olderThanDays} dias...`)
const fecha = new Date()
fecha.setDate(fecha.getDate() - olderThanDays)
// Eliminar metricas detalladas antiguas
const deletedMetrics = await prisma.dispositivoMetrica.deleteMany({
where: {
timestamp: { lt: fecha },
},
})
console.log(`Eliminadas ${deletedMetrics.count} metricas detalladas`)
// Eliminar metricas hourly de mas de 1 año
const yearAgo = new Date()
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
const deletedHourly = await prisma.dispositivoMetricaHourly.deleteMany({
where: {
hora: { lt: yearAgo },
},
})
console.log(`Eliminadas ${deletedHourly.count} metricas hourly`)
// Eliminar audit logs antiguos (mas de 6 meses)
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
const deletedLogs = await prisma.auditLog.deleteMany({
where: {
createdAt: { lt: sixMonthsAgo },
},
})
console.log(`Eliminados ${deletedLogs.count} audit logs`)
// Eliminar alertas resueltas de mas de 30 dias
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const deletedAlerts = await prisma.alerta.deleteMany({
where: {
estado: 'RESUELTA',
resueltaEn: { lt: thirtyDaysAgo },
},
})
console.log(`Eliminadas ${deletedAlerts.count} alertas resueltas`)
console.log('Limpieza de metricas completada')
}
export async function aggregateMetrics(): Promise<void> {
console.log('Agregando metricas por hora...')
// Obtener la hora actual truncada
const now = new Date()
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours())
const previousHour = new Date(currentHour.getTime() - 60 * 60 * 1000)
// Obtener todos los dispositivos con metricas en la hora anterior
const dispositivos = await prisma.dispositivo.findMany({
where: {
metricas: {
some: {
timestamp: {
gte: previousHour,
lt: currentHour,
},
},
},
},
select: { id: true },
})
for (const dispositivo of dispositivos) {
// Verificar si ya existe agregacion para esta hora
const existente = await prisma.dispositivoMetricaHourly.findUnique({
where: {
dispositivoId_hora: {
dispositivoId: dispositivo.id,
hora: previousHour,
},
},
})
if (existente) continue
// Calcular agregaciones
const metricas = await prisma.dispositivoMetrica.findMany({
where: {
dispositivoId: dispositivo.id,
timestamp: {
gte: previousHour,
lt: currentHour,
},
},
})
if (metricas.length === 0) continue
const cpuValues = metricas.map((m) => m.cpuUsage).filter((v): v is number => v !== null)
const ramValues = metricas.map((m) => m.ramUsage).filter((v): v is number => v !== null)
const discoValues = metricas.map((m) => m.discoUsage).filter((v): v is number => v !== null)
const tempValues = metricas.map((m) => m.temperatura).filter((v): v is number => v !== null)
const redInValues = metricas.map((m) => m.redIn).filter((v): v is bigint => v !== null)
const redOutValues = metricas.map((m) => m.redOut).filter((v): v is bigint => v !== null)
await prisma.dispositivoMetricaHourly.create({
data: {
dispositivoId: dispositivo.id,
hora: previousHour,
cpuAvg: cpuValues.length > 0 ? average(cpuValues) : null,
cpuMax: cpuValues.length > 0 ? Math.max(...cpuValues) : null,
ramAvg: ramValues.length > 0 ? average(ramValues) : null,
ramMax: ramValues.length > 0 ? Math.max(...ramValues) : null,
discoAvg: discoValues.length > 0 ? average(discoValues) : null,
tempAvg: tempValues.length > 0 ? average(tempValues) : null,
tempMax: tempValues.length > 0 ? Math.max(...tempValues) : null,
redInTotal: redInValues.length > 0 ? sumBigInt(redInValues) : null,
redOutTotal: redOutValues.length > 0 ? sumBigInt(redOutValues) : null,
},
})
}
console.log(`Metricas agregadas para ${dispositivos.length} dispositivos`)
}
export async function healthCheck(): Promise<void> {
console.log('Ejecutando health check...')
const results: Record<string, boolean> = {}
// Check base de datos
try {
await prisma.$queryRaw`SELECT 1`
results.database = true
} catch {
results.database = false
console.error('Health check: Base de datos no disponible')
}
// Check MeshCentral
try {
const meshClient = new MeshCentralClient()
await meshClient.getDevices()
results.meshcentral = true
} catch {
results.meshcentral = false
console.warn('Health check: MeshCentral no disponible')
}
// Check LibreNMS
try {
const librenmsClient = new LibreNMSClient()
const connected = await librenmsClient.testConnection()
results.librenms = connected
if (!connected) {
console.warn('Health check: LibreNMS no disponible')
}
} catch {
results.librenms = false
console.warn('Health check: LibreNMS no disponible')
}
// Check Headwind
try {
const headwindClient = new HeadwindClient()
const connected = await headwindClient.testConnection()
results.headwind = connected
if (!connected) {
console.warn('Health check: Headwind MDM no disponible')
}
} catch {
results.headwind = false
console.warn('Health check: Headwind MDM no disponible')
}
// Guardar resultados en configuracion
await prisma.configuracion.upsert({
where: { clave: 'health_check_results' },
update: {
valor: {
...results,
timestamp: new Date().toISOString(),
},
},
create: {
clave: 'health_check_results',
valor: {
...results,
timestamp: new Date().toISOString(),
},
tipo: 'json',
categoria: 'sistema',
descripcion: 'Resultados del ultimo health check',
},
})
console.log('Health check completado:', results)
}
function average(values: number[]): number {
if (values.length === 0) return 0
return values.reduce((a, b) => a + b, 0) / values.length
}
function sumBigInt(values: bigint[]): bigint {
return values.reduce((a, b) => a + b, BigInt(0))
}

View File

@@ -0,0 +1,184 @@
import nodemailer from 'nodemailer'
import Twilio from 'twilio'
import { NotificationJobData } from './queue'
// Configurar transporter de email
let emailTransporter: nodemailer.Transporter | null = null
function getEmailTransporter(): nodemailer.Transporter {
if (!emailTransporter) {
emailTransporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_PORT === '465',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
}
return emailTransporter
}
// Configurar cliente de Twilio
let twilioClient: Twilio.Twilio | null = null
function getTwilioClient(): Twilio.Twilio | null {
if (!twilioClient && process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN) {
twilioClient = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
}
return twilioClient
}
export async function sendEmail(data: NotificationJobData): Promise<void> {
const transporter = getEmailTransporter()
try {
await transporter.sendMail({
from: process.env.SMTP_FROM || 'MSP Monitor <noreply@localhost>',
to: data.destinatario,
subject: data.titulo,
text: data.mensaje,
html: formatEmailHtml(data.titulo, data.mensaje),
})
console.log(`Email enviado a ${data.destinatario}`)
} catch (error) {
console.error(`Error enviando email a ${data.destinatario}:`, error)
throw error
}
}
export async function sendSMS(data: NotificationJobData): Promise<void> {
const client = getTwilioClient()
if (!client) {
console.warn('Twilio no configurado, SMS no enviado')
return
}
try {
await client.messages.create({
body: `${data.titulo}\n${data.mensaje}`,
from: process.env.TWILIO_PHONE_NUMBER,
to: data.destinatario,
})
console.log(`SMS enviado a ${data.destinatario}`)
} catch (error) {
console.error(`Error enviando SMS a ${data.destinatario}:`, error)
throw error
}
}
export async function sendWebhook(data: NotificationJobData): Promise<void> {
if (!data.webhookUrl) {
console.warn('URL de webhook no especificada')
return
}
try {
const response = await fetch(data.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: data.mensaje,
})
if (!response.ok) {
throw new Error(`Webhook respondio con status ${response.status}`)
}
console.log(`Webhook enviado a ${data.webhookUrl}`)
} catch (error) {
console.error(`Error enviando webhook a ${data.webhookUrl}:`, error)
throw error
}
}
function formatEmailHtml(titulo: string, mensaje: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #0d1321 0%, #1a2234 100%);
color: #06b6d4;
padding: 20px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f8fafc;
padding: 20px;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
}
.alert-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 10px;
color: #1e293b;
}
.alert-message {
background: white;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #06b6d4;
margin: 15px 0;
white-space: pre-wrap;
}
.footer {
text-align: center;
font-size: 12px;
color: #64748b;
margin-top: 20px;
}
.severity-critical { border-left-color: #ef4444; }
.severity-warning { border-left-color: #f59e0b; }
.severity-info { border-left-color: #3b82f6; }
</style>
</head>
<body>
<div class="header">
<h1>MSP Monitor</h1>
</div>
<div class="content">
<div class="alert-title">${escapeHtml(titulo)}</div>
<div class="alert-message">${escapeHtml(mensaje)}</div>
</div>
<div class="footer">
Este es un mensaje automatico del sistema MSP Monitor.<br>
No responda a este correo.
</div>
</body>
</html>
`.trim()
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
.replace(/\n/g, '<br>')
}

168
src/server/jobs/queue.ts Normal file
View File

@@ -0,0 +1,168 @@
import { Queue, Worker, Job } from 'bullmq'
import Redis from 'ioredis'
const connection = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null,
})
// Colas de trabajo
export const syncQueue = new Queue('sync', { connection })
export const alertQueue = new Queue('alerts', { connection })
export const notificationQueue = new Queue('notifications', { connection })
export const maintenanceQueue = new Queue('maintenance', { connection })
// Tipos de trabajos
export type SyncJobType = 'sync-meshcentral' | 'sync-librenms' | 'sync-headwind'
export type AlertJobType = 'process-alert' | 'check-rules'
export type NotificationJobType = 'send-email' | 'send-sms' | 'send-webhook'
export type MaintenanceJobType = 'cleanup-metrics' | 'aggregate-metrics' | 'health-check'
// Interfaces para datos de trabajos
export interface SyncJobData {
type: SyncJobType
clienteId?: string
}
export interface AlertJobData {
type: AlertJobType
alertaId?: string
dispositivoId?: string
}
export interface NotificationJobData {
type: NotificationJobType
alertaId: string
destinatario: string
titulo: string
mensaje: string
webhookUrl?: string
}
export interface MaintenanceJobData {
type: MaintenanceJobType
olderThanDays?: number
}
// Agregar trabajo de sincronizacion
export async function addSyncJob(data: SyncJobData, delay = 0) {
return syncQueue.add(data.type, data, {
delay,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
})
}
// Agregar trabajo de alerta
export async function addAlertJob(data: AlertJobData) {
return alertQueue.add(data.type, data, {
attempts: 3,
backoff: {
type: 'fixed',
delay: 1000,
},
})
}
// Agregar trabajo de notificacion
export async function addNotificationJob(data: NotificationJobData) {
return notificationQueue.add(data.type, data, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000,
},
})
}
// Agregar trabajo de mantenimiento
export async function addMaintenanceJob(data: MaintenanceJobData, delay = 0) {
return maintenanceQueue.add(data.type, data, {
delay,
attempts: 1,
})
}
// Programar trabajos recurrentes
export async function scheduleRecurringJobs() {
// Sincronizar cada 5 minutos
await syncQueue.add(
'sync-meshcentral',
{ type: 'sync-meshcentral' as SyncJobType },
{
repeat: { every: 5 * 60 * 1000 },
removeOnComplete: 100,
removeOnFail: 50,
}
)
await syncQueue.add(
'sync-librenms',
{ type: 'sync-librenms' as SyncJobType },
{
repeat: { every: 5 * 60 * 1000 },
removeOnComplete: 100,
removeOnFail: 50,
}
)
await syncQueue.add(
'sync-headwind',
{ type: 'sync-headwind' as SyncJobType },
{
repeat: { every: 5 * 60 * 1000 },
removeOnComplete: 100,
removeOnFail: 50,
}
)
// Verificar reglas de alerta cada minuto
await alertQueue.add(
'check-rules',
{ type: 'check-rules' as AlertJobType },
{
repeat: { every: 60 * 1000 },
removeOnComplete: 100,
removeOnFail: 50,
}
)
// Mantenimiento diario a las 3am
await maintenanceQueue.add(
'cleanup-metrics',
{ type: 'cleanup-metrics' as MaintenanceJobType, olderThanDays: 90 },
{
repeat: { pattern: '0 3 * * *' },
removeOnComplete: 10,
removeOnFail: 10,
}
)
// Agregar metricas horarias cada hora
await maintenanceQueue.add(
'aggregate-metrics',
{ type: 'aggregate-metrics' as MaintenanceJobType },
{
repeat: { pattern: '0 * * * *' },
removeOnComplete: 100,
removeOnFail: 50,
}
)
// Health check cada 10 minutos
await maintenanceQueue.add(
'health-check',
{ type: 'health-check' as MaintenanceJobType },
{
repeat: { every: 10 * 60 * 1000 },
removeOnComplete: 100,
removeOnFail: 50,
}
)
console.log('Trabajos recurrentes programados')
}
export { connection, Job }

View File

@@ -0,0 +1,209 @@
import prisma from '@/lib/prisma'
import { HeadwindClient } from '../services/headwind/client'
import { addAlertJob } from './queue'
export async function syncHeadwind(clienteId?: string) {
console.log('Sincronizando con Headwind MDM...')
const headwindClient = new HeadwindClient()
try {
// Obtener dispositivos de Headwind
const headwindDevices = await headwindClient.getDevices()
console.log(`Encontrados ${headwindDevices.length} dispositivos en Headwind MDM`)
// Obtener mapeo de grupos a clientes
const clientes = await prisma.cliente.findMany({
where: clienteId ? { id: clienteId } : { activo: true },
select: { id: true, headwindGrupo: true },
})
const defaultClienteId = clientes[0]?.id
for (const device of headwindDevices) {
try {
// Determinar tipo de dispositivo
const tipo: 'CELULAR' | 'TABLET' = device.model?.toLowerCase().includes('tablet') ? 'TABLET' : 'CELULAR'
// Determinar estado basado en lastUpdate
const lastUpdateTime = device.lastUpdate * 1000 // Convertir a milliseconds
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
const oneHourAgo = Date.now() - 60 * 60 * 1000
let estado: 'ONLINE' | 'OFFLINE' | 'ALERTA' = 'OFFLINE'
if (lastUpdateTime > fiveMinutesAgo) {
estado = 'ONLINE'
} else if (lastUpdateTime > oneHourAgo) {
estado = 'ALERTA' // Dispositivo no reporta pero estuvo activo recientemente
}
// Buscar dispositivo existente
const existente = await prisma.dispositivo.findUnique({
where: { headwindId: device.id },
})
const estadoAnterior = existente?.estado
// Determinar cliente
let clienteIdForDevice = defaultClienteId
// TODO: Implementar mapeo por grupo de Headwind
if (!clienteIdForDevice) {
console.warn(`No hay cliente para dispositivo Headwind ${device.number}`)
continue
}
const dispositivoData = {
nombre: device.description || `${device.manufacturer} ${device.model}`,
tipo,
estado,
imei: device.imei || null,
numeroTelefono: device.phone || null,
sistemaOperativo: 'Android',
versionSO: device.osVersion || null,
fabricante: device.manufacturer || null,
modelo: device.model || null,
bateria: device.batteryLevel || null,
latitud: device.lat || null,
longitud: device.lon || null,
gpsUpdatedAt: device.lat && device.lon ? new Date(lastUpdateTime) : null,
lastSeen: new Date(lastUpdateTime),
}
if (existente) {
await prisma.dispositivo.update({
where: { id: existente.id },
data: dispositivoData,
})
// Verificar cambio de estado para alerta
if (estadoAnterior === 'ONLINE' && estado === 'OFFLINE') {
await crearAlertaDesconexion(existente.id, existente.clienteId, dispositivoData.nombre)
}
// Verificar bateria baja
if (device.batteryLevel !== null && device.batteryLevel < 15) {
await crearAlertaBateriaBaja(existente.id, existente.clienteId, dispositivoData.nombre, device.batteryLevel)
}
} else {
await prisma.dispositivo.create({
data: {
...dispositivoData,
clienteId: clienteIdForDevice,
headwindId: device.id,
},
})
}
// Guardar metricas
if (existente) {
await prisma.dispositivoMetrica.create({
data: {
dispositivoId: existente.id,
bateria: device.batteryLevel || null,
},
})
}
// Sincronizar aplicaciones instaladas
if (existente) {
await syncDeviceApps(headwindClient, device.id, existente.id)
}
} catch (error) {
console.error(`Error sincronizando dispositivo Headwind ${device.id}:`, error)
}
}
console.log('Sincronizacion con Headwind MDM completada')
} catch (error) {
console.error('Error en sincronizacion con Headwind MDM:', error)
throw error
}
}
async function syncDeviceApps(client: HeadwindClient, headwindId: number, dispositivoId: string) {
try {
const apps = await client.getDeviceApps(headwindId)
// Eliminar apps anteriores y agregar nuevas
await prisma.dispositivoSoftware.deleteMany({
where: { dispositivoId },
})
if (apps.length > 0) {
await prisma.dispositivoSoftware.createMany({
data: apps.map((app) => ({
dispositivoId,
nombre: app.name,
version: app.version,
editor: app.pkg,
})),
})
}
} catch (error) {
console.error(`Error sincronizando apps de dispositivo ${headwindId}:`, error)
}
}
async function crearAlertaDesconexion(dispositivoId: string, clienteId: string, nombre: string) {
// Verificar si ya existe una alerta activa de desconexion
const alertaExistente = await prisma.alerta.findFirst({
where: {
dispositivoId,
titulo: { contains: 'desconectado' },
estado: 'ACTIVA',
},
})
if (alertaExistente) return
const alerta = await prisma.alerta.create({
data: {
clienteId,
dispositivoId,
severidad: 'WARNING',
titulo: 'Dispositivo movil desconectado',
mensaje: `El dispositivo ${nombre} no ha reportado en mas de 1 hora`,
origen: 'headwind',
},
})
await addAlertJob({
type: 'process-alert',
alertaId: alerta.id,
})
}
async function crearAlertaBateriaBaja(
dispositivoId: string,
clienteId: string,
nombre: string,
nivel: number
) {
// Verificar si ya existe una alerta activa de bateria
const alertaExistente = await prisma.alerta.findFirst({
where: {
dispositivoId,
titulo: { contains: 'bateria' },
estado: 'ACTIVA',
},
})
if (alertaExistente) return
const alerta = await prisma.alerta.create({
data: {
clienteId,
dispositivoId,
severidad: nivel < 5 ? 'CRITICAL' : 'WARNING',
titulo: 'Bateria baja',
mensaje: `El dispositivo ${nombre} tiene bateria al ${nivel}%`,
origen: 'headwind',
},
})
await addAlertJob({
type: 'process-alert',
alertaId: alerta.id,
})
}

View File

@@ -0,0 +1,232 @@
import prisma from '@/lib/prisma'
import { LibreNMSClient } from '../services/librenms/client'
import { addAlertJob } from './queue'
export async function syncLibreNMS(clienteId?: string) {
console.log('Sincronizando con LibreNMS...')
const librenmsClient = new LibreNMSClient()
try {
// Obtener dispositivos de LibreNMS
const librenmsDevices = await librenmsClient.getDevices()
console.log(`Encontrados ${librenmsDevices.length} dispositivos en LibreNMS`)
// Obtener mapeo de grupos a clientes
const clientes = await prisma.cliente.findMany({
where: clienteId ? { id: clienteId } : { activo: true },
select: { id: true, librenmsGrupo: true },
})
// Por simplicidad, si no hay grupo asignado, usar el primer cliente activo
const defaultClienteId = clientes[0]?.id
for (const device of librenmsDevices) {
try {
// Determinar tipo de dispositivo basado en OS
let tipo: 'ROUTER' | 'SWITCH' | 'FIREWALL' | 'AP' | 'IMPRESORA' | 'OTRO' = 'OTRO'
const os = (device.os || '').toLowerCase()
const hardware = (device.hardware || '').toLowerCase()
if (os.includes('ios') || os.includes('routeros') || hardware.includes('router')) {
tipo = 'ROUTER'
} else if (os.includes('switch') || hardware.includes('switch')) {
tipo = 'SWITCH'
} else if (os.includes('pfsense') || os.includes('fortigate') || os.includes('asa')) {
tipo = 'FIREWALL'
} else if (os.includes('airos') || os.includes('unifi') || hardware.includes('access point')) {
tipo = 'AP'
} else if (os.includes('printer') || hardware.includes('printer')) {
tipo = 'IMPRESORA'
}
// Determinar estado
let estado: 'ONLINE' | 'OFFLINE' | 'ALERTA' = 'OFFLINE'
if (device.status === 1) {
estado = 'ONLINE'
} else if (device.status === 2) {
estado = 'ALERTA'
}
// Buscar dispositivo existente
const existente = await prisma.dispositivo.findUnique({
where: { librenmsId: device.device_id },
})
const estadoAnterior = existente?.estado
// Determinar cliente
let clienteIdForDevice = defaultClienteId
// TODO: Implementar mapeo por grupo de LibreNMS
if (!clienteIdForDevice) {
console.warn(`No hay cliente para dispositivo LibreNMS ${device.hostname}`)
continue
}
const dispositivoData = {
nombre: device.sysName || device.hostname,
tipo,
estado,
ip: device.ip || null,
sistemaOperativo: device.os || null,
versionSO: device.version || null,
fabricante: null, // LibreNMS no siempre provee esto directamente
modelo: device.hardware || null,
serial: device.serial || null,
firmware: device.version || null,
lastSeen: estado === 'ONLINE' ? new Date() : existente?.lastSeen,
}
if (existente) {
await prisma.dispositivo.update({
where: { id: existente.id },
data: dispositivoData,
})
// Verificar cambio de estado para alerta
if (estadoAnterior === 'ONLINE' && estado === 'OFFLINE') {
await crearAlertaDesconexion(existente.id, existente.clienteId, device.sysName || device.hostname)
}
} else {
await prisma.dispositivo.create({
data: {
...dispositivoData,
clienteId: clienteIdForDevice,
librenmsId: device.device_id,
},
})
}
// Guardar metricas de sensores si esta online
if (estado === 'ONLINE' && existente) {
const sensors = await librenmsClient.getDeviceSensors(device.device_id)
let cpuUsage: number | null = null
let ramUsage: number | null = null
let temperatura: number | null = null
for (const sensor of sensors) {
if (sensor.sensor_class === 'processor') {
cpuUsage = sensor.sensor_current
} else if (sensor.sensor_class === 'memory') {
ramUsage = sensor.sensor_current
} else if (sensor.sensor_class === 'temperature') {
if (temperatura === null || sensor.sensor_current > temperatura) {
temperatura = sensor.sensor_current
}
}
}
if (cpuUsage !== null || ramUsage !== null || temperatura !== null) {
await prisma.dispositivoMetrica.create({
data: {
dispositivoId: existente.id,
cpuUsage,
ramUsage,
temperatura,
},
})
// Actualizar metricas actuales en dispositivo
await prisma.dispositivo.update({
where: { id: existente.id },
data: {
cpuUsage,
ramUsage,
temperatura,
},
})
}
}
} catch (error) {
console.error(`Error sincronizando dispositivo LibreNMS ${device.device_id}:`, error)
}
}
// Sincronizar alertas de LibreNMS
await syncLibreNMSAlerts(librenmsClient)
console.log('Sincronizacion con LibreNMS completada')
} catch (error) {
console.error('Error en sincronizacion con LibreNMS:', error)
throw error
}
}
async function syncLibreNMSAlerts(client: LibreNMSClient) {
try {
const alerts = await client.getAlerts()
for (const alert of alerts) {
// Buscar dispositivo asociado
const dispositivo = await prisma.dispositivo.findUnique({
where: { librenmsId: alert.device_id },
})
if (!dispositivo) continue
// Verificar si ya existe esta alerta
const existente = await prisma.alerta.findFirst({
where: {
origen: 'librenms',
origenId: String(alert.id),
},
})
if (!existente) {
// Crear nueva alerta
const nuevaAlerta = await prisma.alerta.create({
data: {
clienteId: dispositivo.clienteId,
dispositivoId: dispositivo.id,
severidad: mapSeveridad(alert.severity),
titulo: `Alerta LibreNMS: ${alert.info}`,
mensaje: alert.info,
origen: 'librenms',
origenId: String(alert.id),
},
})
await addAlertJob({
type: 'process-alert',
alertaId: nuevaAlerta.id,
})
}
}
} catch (error) {
console.error('Error sincronizando alertas de LibreNMS:', error)
}
}
function mapSeveridad(severity: string): 'INFO' | 'WARNING' | 'CRITICAL' {
switch (severity.toLowerCase()) {
case 'critical':
case 'alert':
case 'emergency':
return 'CRITICAL'
case 'warning':
case 'warn':
return 'WARNING'
default:
return 'INFO'
}
}
async function crearAlertaDesconexion(dispositivoId: string, clienteId: string, nombre: string) {
const alerta = await prisma.alerta.create({
data: {
clienteId,
dispositivoId,
severidad: 'CRITICAL',
titulo: 'Dispositivo de red desconectado',
mensaje: `El dispositivo de red ${nombre} no responde`,
origen: 'librenms',
},
})
await addAlertJob({
type: 'process-alert',
alertaId: alerta.id,
})
}

View File

@@ -0,0 +1,150 @@
import prisma from '@/lib/prisma'
import { MeshCentralClient } from '../services/meshcentral/client'
import { addAlertJob } from './queue'
export async function syncMeshCentral(clienteId?: string) {
console.log('Sincronizando con MeshCentral...')
const meshClient = new MeshCentralClient()
try {
// Obtener dispositivos de MeshCentral
const meshDevices = await meshClient.getDevices()
console.log(`Encontrados ${meshDevices.length} dispositivos en MeshCentral`)
// Obtener mapeo de grupos a clientes
const clientes = await prisma.cliente.findMany({
where: clienteId ? { id: clienteId } : { activo: true },
select: { id: true, meshcentralGrupo: true },
})
const grupoToCliente = new Map<string, string>()
clientes.forEach((c) => {
if (c.meshcentralGrupo) {
grupoToCliente.set(c.meshcentralGrupo, c.id)
}
})
for (const meshDevice of meshDevices) {
try {
// Determinar cliente basado en grupo
const meshId = meshDevice._id.split('/')[0] // El mesh ID esta antes del /
const clienteIdForDevice = grupoToCliente.get(meshId)
if (!clienteIdForDevice && clienteId) {
continue // Si estamos sincronizando un cliente especifico, ignorar dispositivos de otros
}
// Determinar tipo de dispositivo
let tipo: 'PC' | 'LAPTOP' | 'SERVIDOR' = 'PC'
const osDesc = (meshDevice.osdesc || '').toLowerCase()
if (osDesc.includes('server')) {
tipo = 'SERVIDOR'
} else if (osDesc.includes('laptop') || osDesc.includes('notebook')) {
tipo = 'LAPTOP'
}
// Determinar estado
let estado: 'ONLINE' | 'OFFLINE' | 'DESCONOCIDO' = 'DESCONOCIDO'
if (meshDevice.conn && meshDevice.conn > 0) {
estado = 'ONLINE'
} else {
estado = 'OFFLINE'
}
// Buscar dispositivo existente o crear nuevo
const existente = await prisma.dispositivo.findUnique({
where: { meshcentralId: meshDevice._id },
})
const estadoAnterior = existente?.estado
const dispositivoData = {
nombre: meshDevice.name,
tipo,
estado,
ip: meshDevice.ip || null,
sistemaOperativo: meshDevice.osdesc || null,
lastSeen: estado === 'ONLINE' ? new Date() : existente?.lastSeen,
}
if (existente) {
await prisma.dispositivo.update({
where: { id: existente.id },
data: dispositivoData,
})
// Verificar cambio de estado para alerta
if (estadoAnterior === 'ONLINE' && estado === 'OFFLINE') {
await crearAlertaDesconexion(existente.id, existente.clienteId)
}
} else if (clienteIdForDevice) {
await prisma.dispositivo.create({
data: {
...dispositivoData,
clienteId: clienteIdForDevice,
meshcentralId: meshDevice._id,
},
})
}
// Guardar metricas si esta online
if (estado === 'ONLINE' && existente) {
// Obtener metricas detalladas de MeshCentral
// Por ahora solo guardamos que esta online
await prisma.dispositivoMetrica.create({
data: {
dispositivoId: existente.id,
cpuUsage: null, // MeshCentral no provee CPU en tiempo real por defecto
ramUsage: null,
discoUsage: null,
},
})
}
} catch (error) {
console.error(`Error sincronizando dispositivo ${meshDevice._id}:`, error)
}
}
// Marcar como offline dispositivos que ya no estan en MeshCentral
const meshIds = meshDevices.map((d) => d._id)
await prisma.dispositivo.updateMany({
where: {
meshcentralId: { not: null, notIn: meshIds },
estado: 'ONLINE',
},
data: {
estado: 'OFFLINE',
},
})
console.log('Sincronizacion con MeshCentral completada')
} catch (error) {
console.error('Error en sincronizacion con MeshCentral:', error)
throw error
}
}
async function crearAlertaDesconexion(dispositivoId: string, clienteId: string) {
const dispositivo = await prisma.dispositivo.findUnique({
where: { id: dispositivoId },
select: { nombre: true },
})
const alerta = await prisma.alerta.create({
data: {
clienteId,
dispositivoId,
severidad: 'WARNING',
titulo: 'Dispositivo desconectado',
mensaje: `El dispositivo ${dispositivo?.nombre || dispositivoId} se ha desconectado`,
origen: 'meshcentral',
},
})
// Programar procesamiento de notificacion
await addAlertJob({
type: 'process-alert',
alertaId: alerta.id,
})
}

151
src/server/jobs/worker.ts Normal file
View File

@@ -0,0 +1,151 @@
import { Worker } from 'bullmq'
import {
connection,
SyncJobData,
AlertJobData,
NotificationJobData,
MaintenanceJobData,
scheduleRecurringJobs,
} from './queue'
import { syncMeshCentral } from './sync-meshcentral.job'
import { syncLibreNMS } from './sync-librenms.job'
import { syncHeadwind } from './sync-headwind.job'
import { processAlert, checkAlertRules } from './alert-processor.job'
import { sendEmail, sendSMS, sendWebhook } from './notification.job'
import { cleanupMetrics, aggregateMetrics, healthCheck } from './maintenance.job'
// Worker de sincronizacion
const syncWorker = new Worker<SyncJobData>(
'sync',
async (job) => {
console.log(`Ejecutando trabajo de sincronizacion: ${job.data.type}`)
switch (job.data.type) {
case 'sync-meshcentral':
await syncMeshCentral(job.data.clienteId)
break
case 'sync-librenms':
await syncLibreNMS(job.data.clienteId)
break
case 'sync-headwind':
await syncHeadwind(job.data.clienteId)
break
}
},
{
connection,
concurrency: 3,
}
)
// Worker de alertas
const alertWorker = new Worker<AlertJobData>(
'alerts',
async (job) => {
console.log(`Ejecutando trabajo de alertas: ${job.data.type}`)
switch (job.data.type) {
case 'process-alert':
if (job.data.alertaId) {
await processAlert(job.data.alertaId)
}
break
case 'check-rules':
await checkAlertRules()
break
}
},
{
connection,
concurrency: 5,
}
)
// Worker de notificaciones
const notificationWorker = new Worker<NotificationJobData>(
'notifications',
async (job) => {
console.log(`Enviando notificacion: ${job.data.type} a ${job.data.destinatario}`)
switch (job.data.type) {
case 'send-email':
await sendEmail(job.data)
break
case 'send-sms':
await sendSMS(job.data)
break
case 'send-webhook':
await sendWebhook(job.data)
break
}
},
{
connection,
concurrency: 10,
}
)
// Worker de mantenimiento
const maintenanceWorker = new Worker<MaintenanceJobData>(
'maintenance',
async (job) => {
console.log(`Ejecutando mantenimiento: ${job.data.type}`)
switch (job.data.type) {
case 'cleanup-metrics':
await cleanupMetrics(job.data.olderThanDays || 90)
break
case 'aggregate-metrics':
await aggregateMetrics()
break
case 'health-check':
await healthCheck()
break
}
},
{
connection,
concurrency: 1,
}
)
// Manejadores de eventos
const workers = [syncWorker, alertWorker, notificationWorker, maintenanceWorker]
workers.forEach((worker) => {
worker.on('completed', (job) => {
console.log(`Trabajo ${job.id} completado`)
})
worker.on('failed', (job, err) => {
console.error(`Trabajo ${job?.id} fallido:`, err.message)
})
worker.on('error', (err) => {
console.error('Error en worker:', err)
})
})
// Iniciar workers
async function start() {
console.log('Iniciando workers de procesamiento...')
// Programar trabajos recurrentes
await scheduleRecurringJobs()
console.log('Workers iniciados y listos')
}
// Cerrar workers gracefully
async function shutdown() {
console.log('Cerrando workers...')
await Promise.all(workers.map((w) => w.close()))
await connection.quit()
console.log('Workers cerrados')
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
start().catch(console.error)

View File

@@ -0,0 +1,307 @@
interface HeadwindConfig {
url: string
token: string
}
interface HeadwindDevice {
id: number
number: string
imei: string
phone: string
model: string
manufacturer: string
osVersion: string
batteryLevel: number
mdmMode: string
lastUpdate: number
lat?: number
lon?: number
enrollTime: number
description?: string
}
interface HeadwindApplication {
id: number
pkg: string
name: string
version: string
versionCode: number
type: string
url?: string
icon?: string
}
interface HeadwindConfiguration {
id: number
name: string
description?: string
password?: string
backgroundColor?: string
iconSize?: string
applications: HeadwindApplication[]
}
export class HeadwindClient {
private config: HeadwindConfig
constructor(config?: Partial<HeadwindConfig>) {
this.config = {
url: config?.url || process.env.HEADWIND_URL || '',
token: config?.token || process.env.HEADWIND_TOKEN || '',
}
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.config.url}/api${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${this.config.token}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Error en Headwind MDM API: ${response.statusText}`)
}
return response.json()
}
// Obtener todos los dispositivos
async getDevices(): Promise<HeadwindDevice[]> {
const data = await this.request<{ data: HeadwindDevice[] }>('/public/v1/devices')
return data.data || []
}
// Obtener dispositivo por ID
async getDevice(deviceId: number): Promise<HeadwindDevice | null> {
try {
const data = await this.request<HeadwindDevice>(`/public/v1/devices/${deviceId}`)
return data
} catch {
return null
}
}
// Obtener dispositivos por grupo
async getDevicesByGroup(groupId: number): Promise<HeadwindDevice[]> {
const data = await this.request<{ data: HeadwindDevice[] }>(`/public/v1/groups/${groupId}/devices`)
return data.data || []
}
// Solicitar actualizacion de ubicacion
async requestLocation(deviceId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/locate`, {
method: 'POST',
})
return true
} catch {
return false
}
}
// Bloquear dispositivo
async lockDevice(deviceId: number, message?: string): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/lock`, {
method: 'POST',
body: JSON.stringify({ message }),
})
return true
} catch {
return false
}
}
// Desbloquear dispositivo
async unlockDevice(deviceId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/unlock`, {
method: 'POST',
})
return true
} catch {
return false
}
}
// Hacer sonar dispositivo
async ringDevice(deviceId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/ring`, {
method: 'POST',
})
return true
} catch {
return false
}
}
// Enviar mensaje al dispositivo
async sendMessage(deviceId: number, message: string): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/message`, {
method: 'POST',
body: JSON.stringify({ message }),
})
return true
} catch {
return false
}
}
// Borrar datos (factory reset)
async wipeDevice(deviceId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/wipe`, {
method: 'POST',
})
return true
} catch {
return false
}
}
// Reiniciar dispositivo
async rebootDevice(deviceId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/reboot`, {
method: 'POST',
})
return true
} catch {
return false
}
}
// Obtener aplicaciones instaladas
async getDeviceApps(deviceId: number): Promise<Array<{
pkg: string
name: string
version: string
}>> {
const data = await this.request<{ data: Array<{
pkg: string
name: string
version: string
}> }>(`/public/v1/devices/${deviceId}/applications`)
return data.data || []
}
// Instalar aplicacion
async installApp(deviceId: number, packageName: string): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/install`, {
method: 'POST',
body: JSON.stringify({ pkg: packageName }),
})
return true
} catch {
return false
}
}
// Desinstalar aplicacion
async removeApp(deviceId: number, packageName: string): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}/command/uninstall`, {
method: 'POST',
body: JSON.stringify({ pkg: packageName }),
})
return true
} catch {
return false
}
}
// Obtener configuraciones disponibles
async getConfigurations(): Promise<HeadwindConfiguration[]> {
const data = await this.request<{ data: HeadwindConfiguration[] }>('/public/v1/configurations')
return data.data || []
}
// Asignar configuracion a dispositivo
async setDeviceConfiguration(deviceId: number, configurationId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify({ configurationId }),
})
return true
} catch {
return false
}
}
// Obtener grupos
async getGroups(): Promise<Array<{ id: number; name: string }>> {
const data = await this.request<{ data: Array<{ id: number; name: string }> }>('/public/v1/groups')
return data.data || []
}
// Asignar dispositivo a grupo
async setDeviceGroup(deviceId: number, groupId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify({ groupId }),
})
return true
} catch {
return false
}
}
// Actualizar descripcion del dispositivo
async updateDeviceDescription(deviceId: number, description: string): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify({ description }),
})
return true
} catch {
return false
}
}
// Eliminar dispositivo
async deleteDevice(deviceId: number): Promise<boolean> {
try {
await this.request(`/public/v1/devices/${deviceId}`, {
method: 'DELETE',
})
return true
} catch {
return false
}
}
// Obtener historial de ubicacion
async getLocationHistory(deviceId: number, from: Date, to: Date): Promise<Array<{
lat: number
lon: number
ts: number
}>> {
const data = await this.request<{ data: Array<{
lat: number
lon: number
ts: number
}> }>(`/public/v1/devices/${deviceId}/locations?from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`)
return data.data || []
}
// Probar conexion
async testConnection(): Promise<boolean> {
try {
await this.request('/public/v1/info')
return true
} catch {
return false
}
}
}
export default HeadwindClient

View File

@@ -0,0 +1,278 @@
interface LibreNMSConfig {
url: string
token: string
}
interface LibreNMSDevice {
device_id: number
hostname: string
sysName: string
ip: string
status: number
os: string
version: string
hardware: string
serial: string
uptime: number
location: string
lat?: number
lng?: number
}
interface LibreNMSPort {
port_id: number
ifIndex: number
ifName: string
ifAlias: string
ifDescr: string
ifSpeed: number
ifOperStatus: string
ifAdminStatus: string
ifInOctets_rate: number
ifOutOctets_rate: number
}
interface LibreNMSAlert {
id: number
device_id: number
rule_id: number
severity: string
state: number
alerted: number
open: number
timestamp: string
info: string
}
export class LibreNMSClient {
private config: LibreNMSConfig
constructor(config?: Partial<LibreNMSConfig>) {
this.config = {
url: config?.url || process.env.LIBRENMS_URL || '',
token: config?.token || process.env.LIBRENMS_TOKEN || '',
}
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.config.url}/api/v0${endpoint}`, {
...options,
headers: {
...options.headers,
'X-Auth-Token': this.config.token,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Error en LibreNMS API: ${response.statusText}`)
}
const data = await response.json()
return data
}
// Obtener todos los dispositivos
async getDevices(): Promise<LibreNMSDevice[]> {
const data = await this.request<{ devices: LibreNMSDevice[] }>('/devices')
return data.devices || []
}
// Obtener dispositivo por ID
async getDevice(deviceId: number): Promise<LibreNMSDevice | null> {
try {
const data = await this.request<{ devices: LibreNMSDevice[] }>(`/devices/${deviceId}`)
return data.devices?.[0] || null
} catch {
return null
}
}
// Obtener dispositivos por grupo
async getDevicesByGroup(groupId: number): Promise<LibreNMSDevice[]> {
const data = await this.request<{ devices: LibreNMSDevice[] }>(`/devicegroups/${groupId}`)
return data.devices || []
}
// Obtener puertos de un dispositivo
async getDevicePorts(deviceId: number): Promise<LibreNMSPort[]> {
const data = await this.request<{ ports: LibreNMSPort[] }>(`/devices/${deviceId}/ports`)
return data.ports || []
}
// Obtener estadisticas de un puerto
async getPortStats(
portId: number,
from: Date,
to: Date
): Promise<Array<{ timestamp: number; in: number; out: number }>> {
const data = await this.request<{
graphs: Array<{ timestamp: number; in: number; out: number }>
}>(`/ports/${portId}/port_bits?from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`)
return data.graphs || []
}
// Obtener alertas activas
async getAlerts(): Promise<LibreNMSAlert[]> {
const data = await this.request<{ alerts: LibreNMSAlert[] }>('/alerts?state=1')
return data.alerts || []
}
// Obtener alertas de un dispositivo
async getDeviceAlerts(deviceId: number): Promise<LibreNMSAlert[]> {
const data = await this.request<{ alerts: LibreNMSAlert[] }>(`/devices/${deviceId}/alerts`)
return data.alerts || []
}
// Obtener historial de alertas
async getAlertLog(limit = 100): Promise<LibreNMSAlert[]> {
const data = await this.request<{ alerts: LibreNMSAlert[] }>(`/alerts?limit=${limit}`)
return data.alerts || []
}
// Obtener grupos de dispositivos
async getDeviceGroups(): Promise<Array<{ id: number; name: string; desc: string }>> {
const data = await this.request<{ groups: Array<{ id: number; name: string; desc: string }> }>('/devicegroups')
return data.groups || []
}
// Obtener enlaces de red (para topologia)
async getLinks(): Promise<Array<{
id: number
local_device_id: number
local_port_id: number
local_port: string
remote_device_id: number
remote_port_id: number
remote_port: string
protocol: string
}>> {
const data = await this.request<{ links: Array<{
id: number
local_device_id: number
local_port_id: number
local_port: string
remote_device_id: number
remote_port_id: number
remote_port: string
protocol: string
}> }>('/resources/links')
return data.links || []
}
// Obtener sensores de un dispositivo
async getDeviceSensors(deviceId: number): Promise<Array<{
sensor_id: number
sensor_class: string
sensor_descr: string
sensor_current: number
sensor_limit: number
sensor_limit_warn: number
}>> {
const data = await this.request<{ sensors: Array<{
sensor_id: number
sensor_class: string
sensor_descr: string
sensor_current: number
sensor_limit: number
sensor_limit_warn: number
}> }>(`/devices/${deviceId}/health`)
return data.sensors || []
}
// Obtener logs de dispositivo
async getDeviceLogs(deviceId: number, limit = 50): Promise<Array<{
datetime: string
msg: string
type: string
reference: string
}>> {
const data = await this.request<{ logs: Array<{
datetime: string
msg: string
type: string
reference: string
}> }>(`/devices/${deviceId}/logs?limit=${limit}`)
return data.logs || []
}
// Reconocer alerta
async ackAlert(alertId: number): Promise<boolean> {
try {
await this.request(`/alerts/${alertId}`, {
method: 'PUT',
body: JSON.stringify({ state: 2 }), // 2 = acknowledged
})
return true
} catch {
return false
}
}
// Agregar dispositivo
async addDevice(hostname: string, snmpConfig: {
version: 'v1' | 'v2c' | 'v3'
community?: string
authlevel?: string
authname?: string
authpass?: string
authalgo?: string
cryptopass?: string
cryptoalgo?: string
}): Promise<{ device_id: number } | null> {
try {
const data = await this.request<{ device_id: number }>('/devices', {
method: 'POST',
body: JSON.stringify({
hostname,
...snmpConfig,
}),
})
return data
} catch {
return null
}
}
// Eliminar dispositivo
async deleteDevice(deviceId: number): Promise<boolean> {
try {
await this.request(`/devices/${deviceId}`, {
method: 'DELETE',
})
return true
} catch {
return false
}
}
// Obtener grafico de CPU
async getDeviceCpuGraph(deviceId: number, from: Date, to: Date): Promise<string> {
const url = `${this.config.url}/graph.php?device=${deviceId}&type=device_processor&from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`
return url
}
// Obtener grafico de memoria
async getDeviceMemoryGraph(deviceId: number, from: Date, to: Date): Promise<string> {
const url = `${this.config.url}/graph.php?device=${deviceId}&type=device_mempool&from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`
return url
}
// Obtener grafico de almacenamiento
async getDeviceStorageGraph(deviceId: number, from: Date, to: Date): Promise<string> {
const url = `${this.config.url}/graph.php?device=${deviceId}&type=device_storage&from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`
return url
}
// Probar conexion
async testConnection(): Promise<boolean> {
try {
await this.request('/system')
return true
} catch {
return false
}
}
}
export default LibreNMSClient

View File

@@ -0,0 +1,276 @@
import WebSocket from 'ws'
interface MeshCentralConfig {
url: string
user: string
pass: string
domain: string
}
interface MeshDevice {
_id: string
name: string
host: string
agent?: {
id: number
caps: number
}
conn: number
pwr: number
ip?: string
osdesc?: string
}
interface CommandResult {
success: boolean
output?: string
error?: string
}
export class MeshCentralClient {
private config: MeshCentralConfig
private ws: WebSocket | null = null
private authToken: string | null = null
private messageId = 0
constructor(config?: Partial<MeshCentralConfig>) {
this.config = {
url: config?.url || process.env.MESHCENTRAL_URL || '',
user: config?.user || process.env.MESHCENTRAL_USER || '',
pass: config?.pass || process.env.MESHCENTRAL_PASS || '',
domain: config?.domain || process.env.MESHCENTRAL_DOMAIN || 'default',
}
}
private async authenticate(): Promise<string> {
const response = await fetch(`${this.config.url}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'login',
username: this.config.user,
password: this.config.pass,
}),
})
if (!response.ok) {
throw new Error('Error de autenticacion con MeshCentral')
}
const data = await response.json()
if (!data.token) {
throw new Error('No se recibio token de MeshCentral')
}
this.authToken = data.token
return data.token
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
if (!this.authToken) {
await this.authenticate()
}
const response = await fetch(`${this.config.url}${endpoint}`, {
...options,
headers: {
...options.headers,
'x-meshauth': this.authToken!,
'Content-Type': 'application/json',
},
})
if (response.status === 401) {
// Token expirado, reautenticar
await this.authenticate()
return this.request(endpoint, options)
}
if (!response.ok) {
throw new Error(`Error en MeshCentral API: ${response.statusText}`)
}
return response.json()
}
// Obtener todos los dispositivos
async getDevices(): Promise<MeshDevice[]> {
const data = await this.request<{ nodes: Record<string, MeshDevice[]> }>('/api/meshes')
const devices: MeshDevice[] = []
for (const meshId in data.nodes) {
devices.push(...data.nodes[meshId])
}
return devices
}
// Obtener dispositivo por ID
async getDevice(nodeId: string): Promise<MeshDevice | null> {
const devices = await this.getDevices()
return devices.find(d => d._id === nodeId) || null
}
// Obtener grupos (meshes)
async getMeshes(): Promise<Array<{ _id: string; name: string; desc: string }>> {
const data = await this.request<{ meshes: Array<{ _id: string; name: string; desc: string }> }>('/api/meshes')
return data.meshes || []
}
// Obtener dispositivos de un grupo
async getMeshDevices(meshId: string): Promise<MeshDevice[]> {
const data = await this.request<{ nodes: Record<string, MeshDevice[]> }>(`/api/meshes/${meshId}`)
return data.nodes[meshId] || []
}
// Ejecutar comando en dispositivo
async runCommand(nodeId: string, command: string, type: 'powershell' | 'cmd' | 'bash' = 'powershell'): Promise<CommandResult> {
try {
const data = await this.request<{ success: boolean; output?: string; error?: string }>('/api/runcommand', {
method: 'POST',
body: JSON.stringify({
nodeids: [nodeId],
type,
command,
runAsUser: 0, // Run as current user
}),
})
return {
success: data.success,
output: data.output,
error: data.error,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Error desconocido',
}
}
}
// Accion de energia (restart, shutdown, sleep, wake)
async powerAction(nodeId: string, action: 'restart' | 'shutdown' | 'sleep' | 'wake'): Promise<boolean> {
const actionMap = {
restart: 3,
shutdown: 2,
sleep: 4,
wake: 1,
}
try {
await this.request('/api/powersave', {
method: 'POST',
body: JSON.stringify({
nodeids: [nodeId],
action: actionMap[action],
}),
})
return true
} catch {
return false
}
}
// Conectar WebSocket para eventos en tiempo real
async connectWebSocket(onMessage: (event: { action: string; data: unknown }) => void): Promise<void> {
if (!this.authToken) {
await this.authenticate()
}
const wsUrl = this.config.url.replace('https://', 'wss://').replace('http://', 'ws://')
this.ws = new WebSocket(`${wsUrl}/control.ashx`, {
headers: {
'x-meshauth': this.authToken!,
},
})
this.ws.on('open', () => {
console.log('WebSocket conectado a MeshCentral')
// Autenticar en WebSocket
this.ws?.send(
JSON.stringify({
action: 'authcookie',
cookie: this.authToken,
})
)
})
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
onMessage(message)
} catch {
// Ignorar mensajes no JSON
}
})
this.ws.on('error', (error) => {
console.error('Error en WebSocket de MeshCentral:', error)
})
this.ws.on('close', () => {
console.log('WebSocket de MeshCentral cerrado')
// Reconectar despues de 5 segundos
setTimeout(() => this.connectWebSocket(onMessage), 5000)
})
}
// Desconectar WebSocket
disconnectWebSocket(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
// Obtener informacion detallada del sistema
async getSystemInfo(nodeId: string): Promise<Record<string, unknown>> {
const data = await this.request<{ info: Record<string, unknown> }>(`/api/nodes/${nodeId}/info`)
return data.info || {}
}
// Obtener procesos en ejecucion
async getProcesses(nodeId: string): Promise<Array<{ pid: number; name: string; user: string }>> {
const data = await this.request<{ processes: Array<{ pid: number; name: string; user: string }> }>(`/api/nodes/${nodeId}/processes`)
return data.processes || []
}
// Obtener servicios (Windows)
async getServices(nodeId: string): Promise<Array<{ name: string; displayName: string; status: string }>> {
const data = await this.request<{ services: Array<{ name: string; displayName: string; status: string }> }>(`/api/nodes/${nodeId}/services`)
return data.services || []
}
// Enviar mensaje al usuario del dispositivo
async sendMessage(nodeId: string, title: string, message: string): Promise<boolean> {
try {
await this.request('/api/message', {
method: 'POST',
body: JSON.stringify({
nodeids: [nodeId],
title,
message,
}),
})
return true
} catch {
return false
}
}
// Generar URL de conexion remota
getRemoteUrl(nodeId: string, type: 'desktop' | 'terminal' | 'files'): string {
const viewModes = {
desktop: 11,
terminal: 12,
files: 13,
}
return `${this.config.url}/?node=${nodeId}&viewmode=${viewModes[type]}`
}
}
export default MeshCentralClient

View File

@@ -0,0 +1,263 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const alertasRouter = router({
// Listar alertas
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
estado: z.enum(['ACTIVA', 'RECONOCIDA', 'RESUELTA']).optional(),
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']).optional(),
dispositivoId: z.string().optional(),
desde: z.date().optional(),
hasta: z.date().optional(),
page: z.number().default(1),
limit: z.number().default(50),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, estado, severidad, dispositivoId, desde, hasta, page = 1, limit = 50 } = input || {}
const where = {
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(severidad ? { severidad } : {}),
...(dispositivoId ? { dispositivoId } : {}),
...(desde || hasta ? {
createdAt: {
...(desde ? { gte: desde } : {}),
...(hasta ? { lte: hasta } : {}),
},
} : {}),
}
const [alertas, total] = await Promise.all([
ctx.prisma.alerta.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
dispositivo: { select: { id: true, nombre: true, tipo: true, ip: true } },
},
orderBy: [{ severidad: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.alerta.count({ where }),
])
return {
alertas,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener alerta por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const alerta = await ctx.prisma.alerta.findUnique({
where: { id: input.id },
include: {
cliente: true,
dispositivo: true,
regla: true,
},
})
if (!alerta) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return alerta
}),
// Reconocer alerta
reconocer: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const alerta = await ctx.prisma.alerta.findUnique({
where: { id: input.id },
})
if (!alerta) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return ctx.prisma.alerta.update({
where: { id: input.id },
data: {
estado: 'RECONOCIDA',
reconocidaPor: ctx.user.id,
reconocidaEn: new Date(),
},
})
}),
// Resolver alerta
resolver: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const alerta = await ctx.prisma.alerta.findUnique({
where: { id: input.id },
})
if (!alerta) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return ctx.prisma.alerta.update({
where: { id: input.id },
data: {
estado: 'RESUELTA',
resueltaPor: ctx.user.id,
resueltaEn: new Date(),
},
})
}),
// Reconocer multiples alertas
reconocerMultiples: protectedProcedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ ctx, input }) => {
const where = {
id: { in: input.ids },
estado: 'ACTIVA' as const,
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
}
return ctx.prisma.alerta.updateMany({
where,
data: {
estado: 'RECONOCIDA',
reconocidaPor: ctx.user.id,
reconocidaEn: new Date(),
},
})
}),
// Conteo de alertas activas
conteoActivas: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
estado: 'ACTIVA' as const,
...(clienteId ? { clienteId } : {}),
}
const [total, critical, warning, info] = await Promise.all([
ctx.prisma.alerta.count({ where }),
ctx.prisma.alerta.count({ where: { ...where, severidad: 'CRITICAL' } }),
ctx.prisma.alerta.count({ where: { ...where, severidad: 'WARNING' } }),
ctx.prisma.alerta.count({ where: { ...where, severidad: 'INFO' } }),
])
return { total, critical, warning, info }
}),
// ==================== REGLAS ====================
reglas: router({
list: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
return ctx.prisma.alertaRegla.findMany({
where: {
OR: [
{ clienteId: null }, // Reglas globales
...(clienteId ? [{ clienteId }] : []),
],
},
orderBy: [{ clienteId: 'asc' }, { nombre: 'asc' }],
})
}),
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const regla = await ctx.prisma.alertaRegla.findUnique({
where: { id: input.id },
})
if (!regla) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Regla no encontrada' })
}
return regla
}),
create: adminProcedure
.input(
z.object({
clienteId: z.string().optional(),
nombre: z.string(),
descripcion: z.string().optional(),
tipoDispositivo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
metrica: z.string(),
operador: z.enum(['>', '<', '>=', '<=', '==']),
umbral: z.number(),
duracionMinutos: z.number().default(5),
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']),
notificarEmail: z.boolean().default(true),
notificarSms: z.boolean().default(false),
notificarWebhook: z.boolean().default(false),
webhookUrl: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.alertaRegla.create({ data: input })
}),
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().optional(),
descripcion: z.string().optional().nullable(),
activa: z.boolean().optional(),
tipoDispositivo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional().nullable(),
metrica: z.string().optional(),
operador: z.enum(['>', '<', '>=', '<=', '==']).optional(),
umbral: z.number().optional(),
duracionMinutos: z.number().optional(),
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']).optional(),
notificarEmail: z.boolean().optional(),
notificarSms: z.boolean().optional(),
notificarWebhook: z.boolean().optional(),
webhookUrl: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return ctx.prisma.alertaRegla.update({ where: { id }, data })
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.alertaRegla.delete({ where: { id: input.id } })
}),
}),
})

View File

@@ -0,0 +1,173 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import bcrypt from 'bcryptjs'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { createSession, validateMeshCentralUser, setSessionCookie, clearSession } from '@/lib/auth'
export const authRouter = router({
// Login con email/password
login: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { email: input.email },
})
if (!usuario || !usuario.passwordHash) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Credenciales invalidas',
})
}
const validPassword = await bcrypt.compare(input.password, usuario.passwordHash)
if (!validPassword) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Credenciales invalidas',
})
}
if (!usuario.activo) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Usuario desactivado',
})
}
// Actualizar lastLogin
await ctx.prisma.usuario.update({
where: { id: usuario.id },
data: { lastLogin: new Date() },
})
const token = await createSession({
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
clienteId: usuario.clienteId,
meshcentralUser: usuario.meshcentralUser,
})
await setSessionCookie(token)
return {
success: true,
user: {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
},
}
}),
// Login con MeshCentral SSO
loginMeshCentral: publicProcedure
.input(
z.object({
username: z.string(),
token: z.string(),
})
)
.mutation(async ({ input }) => {
const user = await validateMeshCentralUser(input.username, input.token)
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Token de MeshCentral invalido',
})
}
const sessionToken = await createSession(user)
await setSessionCookie(sessionToken)
return {
success: true,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
rol: user.rol,
},
}
}),
// Logout
logout: protectedProcedure.mutation(async () => {
await clearSession()
return { success: true }
}),
// Obtener usuario actual
me: protectedProcedure.query(async ({ ctx }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { id: ctx.user.id },
include: {
cliente: true,
permisos: true,
},
})
if (!usuario) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Usuario no encontrado',
})
}
return {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
avatar: usuario.avatar,
cliente: usuario.cliente,
permisos: usuario.permisos,
}
}),
// Cambiar password
changePassword: protectedProcedure
.input(
z.object({
currentPassword: z.string(),
newPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { id: ctx.user.id },
})
if (!usuario || !usuario.passwordHash) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No se puede cambiar password para usuarios SSO',
})
}
const validPassword = await bcrypt.compare(input.currentPassword, usuario.passwordHash)
if (!validPassword) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Password actual incorrecto',
})
}
const newHash = await bcrypt.hash(input.newPassword, 12)
await ctx.prisma.usuario.update({
where: { id: ctx.user.id },
data: { passwordHash: newHash },
})
return { success: true }
}),
})

View File

@@ -0,0 +1,382 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { HeadwindClient } from '@/server/services/headwind/client'
export const celularesRouter = router({
// Listar celulares y tablets
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, estado, search, page = 1, limit = 20 } = input || {}
const where = {
tipo: { in: ['CELULAR', 'TABLET'] as const },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ imei: { contains: search } },
{ numeroTelefono: { contains: search } },
],
} : {}),
}
const [dispositivos, total] = await Promise.all([
ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
ubicacion: { select: { id: true, nombre: true } },
},
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.dispositivo.count({ where }),
])
return {
dispositivos,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener celular por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
include: {
cliente: true,
ubicacion: true,
alertas: {
where: { estado: 'ACTIVA' },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return dispositivo
}),
// Obtener ubicacion actual
ubicacion: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
select: {
id: true,
nombre: true,
latitud: true,
longitud: true,
gpsUpdatedAt: true,
clienteId: true,
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return {
lat: dispositivo.latitud,
lng: dispositivo.longitud,
updatedAt: dispositivo.gpsUpdatedAt,
}
}),
// Solicitar actualizacion de ubicacion
solicitarUbicacion: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.requestLocation(dispositivo.headwindId)
return { success: true, message: 'Solicitud de ubicacion enviada' }
}),
// Bloquear dispositivo
bloquear: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
mensaje: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.lockDevice(dispositivo.headwindId, input.mensaje)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'bloquear',
recurso: 'celular',
detalles: { mensaje: input.mensaje },
},
})
return { success: true }
}),
// Desbloquear dispositivo
desbloquear: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.unlockDevice(dispositivo.headwindId)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'desbloquear',
recurso: 'celular',
},
})
return { success: true }
}),
// Hacer sonar dispositivo
sonar: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.ringDevice(dispositivo.headwindId)
return { success: true }
}),
// Enviar mensaje al dispositivo
enviarMensaje: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
mensaje: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.sendMessage(dispositivo.headwindId, input.mensaje)
return { success: true }
}),
// Borrar datos (factory reset)
borrarDatos: adminProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
const headwindClient = new HeadwindClient()
await headwindClient.wipeDevice(dispositivo.headwindId)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'borrar_datos',
recurso: 'celular',
},
})
return { success: true }
}),
// Instalar aplicacion
instalarApp: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
packageName: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.installApp(dispositivo.headwindId, input.packageName)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'instalar_app',
recurso: 'celular',
detalles: { packageName: input.packageName },
},
})
return { success: true }
}),
// Desinstalar aplicacion
desinstalarApp: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
packageName: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.removeApp(dispositivo.headwindId, input.packageName)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'desinstalar_app',
recurso: 'celular',
detalles: { packageName: input.packageName },
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,264 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const clientesRouter = router({
// Listar clientes
list: protectedProcedure
.input(
z.object({
search: z.string().optional(),
activo: z.boolean().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { search, activo, page = 1, limit = 20 } = input || {}
// Si el usuario tiene clienteId, solo puede ver su cliente
const where = {
...(ctx.user.clienteId ? { id: ctx.user.clienteId } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ codigo: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
...(activo !== undefined ? { activo } : {}),
}
const [clientes, total] = await Promise.all([
ctx.prisma.cliente.findMany({
where,
include: {
_count: {
select: { dispositivos: true, usuarios: true },
},
},
orderBy: { nombre: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.cliente.count({ where }),
])
return {
clientes,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener cliente por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== input.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const cliente = await ctx.prisma.cliente.findUnique({
where: { id: input.id },
include: {
ubicaciones: true,
_count: {
select: {
dispositivos: true,
usuarios: true,
alertas: { where: { estado: 'ACTIVA' } },
},
},
},
})
if (!cliente) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Cliente no encontrado' })
}
return cliente
}),
// Crear cliente
create: adminProcedure
.input(
z.object({
nombre: z.string().min(1),
codigo: z.string().min(1),
email: z.string().email().optional(),
telefono: z.string().optional(),
notas: z.string().optional(),
meshcentralGrupo: z.string().optional(),
librenmsGrupo: z.number().optional(),
headwindGrupo: z.number().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verificar codigo unico
const existe = await ctx.prisma.cliente.findUnique({
where: { codigo: input.codigo },
})
if (existe) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Ya existe un cliente con ese codigo',
})
}
return ctx.prisma.cliente.create({
data: input,
})
}),
// Actualizar cliente
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().min(1).optional(),
email: z.string().email().optional().nullable(),
telefono: z.string().optional().nullable(),
activo: z.boolean().optional(),
notas: z.string().optional().nullable(),
meshcentralGrupo: z.string().optional().nullable(),
librenmsGrupo: z.number().optional().nullable(),
headwindGrupo: z.number().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return ctx.prisma.cliente.update({
where: { id },
data,
})
}),
// Eliminar cliente
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.cliente.delete({
where: { id: input.id },
})
}),
// Estadisticas del dashboard por cliente
dashboardStats: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = clienteId ? { clienteId } : {}
const [
totalDispositivos,
dispositivosOnline,
dispositivosOffline,
dispositivosAlerta,
alertasActivas,
alertasCriticas,
] = await Promise.all([
ctx.prisma.dispositivo.count({ where }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'OFFLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ALERTA' } }),
ctx.prisma.alerta.count({
where: { ...where, estado: 'ACTIVA' },
}),
ctx.prisma.alerta.count({
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
}),
])
return {
totalDispositivos,
dispositivosOnline,
dispositivosOffline,
dispositivosAlerta,
alertasActivas,
alertasCriticas,
sesionesActivas: 0, // TODO: implementar
}
}),
// Ubicaciones
ubicaciones: router({
list: protectedProcedure
.input(z.object({ clienteId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.clienteUbicacion.findMany({
where: { clienteId: input.clienteId },
orderBy: [{ principal: 'desc' }, { nombre: 'asc' }],
})
}),
create: adminProcedure
.input(
z.object({
clienteId: z.string(),
nombre: z.string(),
direccion: z.string().optional(),
ciudad: z.string().optional(),
estado: z.string().optional(),
pais: z.string().default('Mexico'),
codigoPostal: z.string().optional(),
latitud: z.number().optional(),
longitud: z.number().optional(),
principal: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// Si es principal, quitar principal a las demas
if (input.principal) {
await ctx.prisma.clienteUbicacion.updateMany({
where: { clienteId: input.clienteId },
data: { principal: false },
})
}
return ctx.prisma.clienteUbicacion.create({ data: input })
}),
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().optional(),
direccion: z.string().optional().nullable(),
ciudad: z.string().optional().nullable(),
estado: z.string().optional().nullable(),
pais: z.string().optional(),
codigoPostal: z.string().optional().nullable(),
latitud: z.number().optional().nullable(),
longitud: z.number().optional().nullable(),
principal: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
// Si es principal, quitar principal a las demas
if (data.principal) {
const ubicacion = await ctx.prisma.clienteUbicacion.findUnique({ where: { id } })
if (ubicacion) {
await ctx.prisma.clienteUbicacion.updateMany({
where: { clienteId: ubicacion.clienteId, id: { not: id } },
data: { principal: false },
})
}
}
return ctx.prisma.clienteUbicacion.update({ where: { id }, data })
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.clienteUbicacion.delete({ where: { id: input.id } })
}),
}),
})

View File

@@ -0,0 +1,370 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, superAdminProcedure } from '../trpc'
export const configuracionRouter = router({
// Obtener todas las configuraciones
list: adminProcedure
.input(z.object({ categoria: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const where = input?.categoria ? { categoria: input.categoria } : {}
const configuraciones = await ctx.prisma.configuracion.findMany({
where,
orderBy: [{ categoria: 'asc' }, { clave: 'asc' }],
})
// Ocultar valores sensibles si no es super admin
if (ctx.user.rol !== 'SUPER_ADMIN') {
return configuraciones.map(c => ({
...c,
valor: c.clave.includes('password') || c.clave.includes('token') || c.clave.includes('secret')
? '********'
: c.valor,
}))
}
return configuraciones
}),
// Obtener una configuracion por clave
get: adminProcedure
.input(z.object({ clave: z.string() }))
.query(async ({ ctx, input }) => {
const config = await ctx.prisma.configuracion.findUnique({
where: { clave: input.clave },
})
if (!config) {
return null
}
// Ocultar valores sensibles
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
(input.clave.includes('password') ||
input.clave.includes('token') ||
input.clave.includes('secret'))
) {
return { ...config, valor: '********' }
}
return config
}),
// Establecer configuracion
set: superAdminProcedure
.input(
z.object({
clave: z.string(),
valor: z.any(),
tipo: z.enum(['string', 'number', 'boolean', 'json']).default('string'),
categoria: z.string().default('general'),
descripcion: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.configuracion.upsert({
where: { clave: input.clave },
update: {
valor: input.valor,
tipo: input.tipo,
descripcion: input.descripcion,
},
create: {
clave: input.clave,
valor: input.valor,
tipo: input.tipo,
categoria: input.categoria,
descripcion: input.descripcion,
},
})
}),
// Eliminar configuracion
delete: superAdminProcedure
.input(z.object({ clave: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.configuracion.delete({
where: { clave: input.clave },
})
}),
// Configuraciones de integracion
integraciones: router({
// Obtener estado de integraciones
status: adminProcedure.query(async ({ ctx }) => {
const [meshcentral, librenms, headwind] = await Promise.all([
ctx.prisma.configuracion.findFirst({
where: { clave: 'meshcentral_url' },
}),
ctx.prisma.configuracion.findFirst({
where: { clave: 'librenms_url' },
}),
ctx.prisma.configuracion.findFirst({
where: { clave: 'headwind_url' },
}),
])
return {
meshcentral: {
configurado: !!meshcentral,
url: meshcentral?.valor as string | undefined,
},
librenms: {
configurado: !!librenms,
url: librenms?.valor as string | undefined,
},
headwind: {
configurado: !!headwind,
url: headwind?.valor as string | undefined,
},
}
}),
// Configurar MeshCentral
setMeshCentral: superAdminProcedure
.input(
z.object({
url: z.string().url(),
user: z.string(),
password: z.string(),
domain: z.string().default('default'),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_url' },
update: { valor: input.url },
create: {
clave: 'meshcentral_url',
valor: input.url,
tipo: 'string',
categoria: 'integracion',
descripcion: 'URL de MeshCentral',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_user' },
update: { valor: input.user },
create: {
clave: 'meshcentral_user',
valor: input.user,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Usuario de MeshCentral',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_password' },
update: { valor: input.password },
create: {
clave: 'meshcentral_password',
valor: input.password,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Password de MeshCentral',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_domain' },
update: { valor: input.domain },
create: {
clave: 'meshcentral_domain',
valor: input.domain,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Dominio de MeshCentral',
},
}),
])
return { success: true }
}),
// Configurar LibreNMS
setLibreNMS: superAdminProcedure
.input(
z.object({
url: z.string().url(),
token: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'librenms_url' },
update: { valor: input.url },
create: {
clave: 'librenms_url',
valor: input.url,
tipo: 'string',
categoria: 'integracion',
descripcion: 'URL de LibreNMS',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'librenms_token' },
update: { valor: input.token },
create: {
clave: 'librenms_token',
valor: input.token,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Token de API de LibreNMS',
},
}),
])
return { success: true }
}),
// Configurar Headwind MDM
setHeadwind: superAdminProcedure
.input(
z.object({
url: z.string().url(),
token: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'headwind_url' },
update: { valor: input.url },
create: {
clave: 'headwind_url',
valor: input.url,
tipo: 'string',
categoria: 'integracion',
descripcion: 'URL de Headwind MDM',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'headwind_token' },
update: { valor: input.token },
create: {
clave: 'headwind_token',
valor: input.token,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Token de API de Headwind MDM',
},
}),
])
return { success: true }
}),
// Probar conexion
test: superAdminProcedure
.input(z.object({ integracion: z.enum(['meshcentral', 'librenms', 'headwind']) }))
.mutation(async ({ input }) => {
// TODO: Implementar prueba de conexion real
return {
success: true,
message: `Conexion a ${input.integracion} exitosa`,
}
}),
}),
// Configuraciones de notificaciones
notificaciones: router({
// Obtener configuracion de SMTP
getSmtp: adminProcedure.query(async ({ ctx }) => {
const [host, port, user, from] = await Promise.all([
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_host' } }),
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_port' } }),
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_user' } }),
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_from' } }),
])
return {
host: host?.valor as string | undefined,
port: port?.valor as number | undefined,
user: user?.valor as string | undefined,
from: from?.valor as string | undefined,
}
}),
// Configurar SMTP
setSmtp: superAdminProcedure
.input(
z.object({
host: z.string(),
port: z.number(),
user: z.string(),
password: z.string(),
from: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_host' },
update: { valor: input.host },
create: {
clave: 'smtp_host',
valor: input.host,
tipo: 'string',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_port' },
update: { valor: input.port },
create: {
clave: 'smtp_port',
valor: input.port,
tipo: 'number',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_user' },
update: { valor: input.user },
create: {
clave: 'smtp_user',
valor: input.user,
tipo: 'string',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_password' },
update: { valor: input.password },
create: {
clave: 'smtp_password',
valor: input.password,
tipo: 'string',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_from' },
update: { valor: input.from },
create: {
clave: 'smtp_from',
valor: input.from,
tipo: 'string',
categoria: 'notificacion',
},
}),
])
return { success: true }
}),
// Probar email
testEmail: superAdminProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
// TODO: Implementar envio de email de prueba
return {
success: true,
message: `Email de prueba enviado a ${input.email}`,
}
}),
}),
})

View File

@@ -0,0 +1,333 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { MeshCentralClient } from '@/server/services/meshcentral/client'
export const equiposRouter = router({
// Listar equipos de computo (PC, laptop, servidor)
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR']).optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
const where = {
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as const },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ ip: { contains: search } },
{ serial: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
}
const [dispositivos, total] = await Promise.all([
ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
ubicacion: { select: { id: true, nombre: true } },
},
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.dispositivo.count({ where }),
])
return {
dispositivos,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener equipo por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
include: {
cliente: true,
ubicacion: true,
software: {
orderBy: { nombre: 'asc' },
take: 100,
},
alertas: {
where: { estado: 'ACTIVA' },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return dispositivo
}),
// Obtener metricas historicas
metricas: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
periodo: z.enum(['1h', '6h', '24h', '7d', '30d']).default('24h'),
})
)
.query(async ({ ctx, input }) => {
const now = new Date()
let desde: Date
switch (input.periodo) {
case '1h':
desde = new Date(now.getTime() - 60 * 60 * 1000)
break
case '6h':
desde = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case '24h':
desde = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
desde = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
desde = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
}
// Para periodos cortos usar metricas detalladas, para largos usar hourly
if (['1h', '6h', '24h'].includes(input.periodo)) {
return ctx.prisma.dispositivoMetrica.findMany({
where: {
dispositivoId: input.dispositivoId,
timestamp: { gte: desde },
},
orderBy: { timestamp: 'asc' },
})
} else {
return ctx.prisma.dispositivoMetricaHourly.findMany({
where: {
dispositivoId: input.dispositivoId,
hora: { gte: desde },
},
orderBy: { hora: 'asc' },
})
}
}),
// Iniciar sesion remota
iniciarSesion: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
tipo: z.enum(['desktop', 'terminal', 'files']),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// Crear registro de sesion
const sesion = await ctx.prisma.sesionRemota.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
tipo: input.tipo,
},
})
// Generar URL de MeshCentral para la sesion
const meshUrl = process.env.MESHCENTRAL_URL
const sessionUrl = `${meshUrl}/?node=${dispositivo.meshcentralId}&viewmode=${
input.tipo === 'desktop' ? '11' : input.tipo === 'terminal' ? '12' : '13'
}`
return {
sesionId: sesion.id,
url: sessionUrl,
}
}),
// Finalizar sesion remota
finalizarSesion: protectedProcedure
.input(z.object({ sesionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const sesion = await ctx.prisma.sesionRemota.findUnique({
where: { id: input.sesionId },
})
if (!sesion || sesion.usuarioId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Sesion no encontrada' })
}
const ahora = new Date()
const duracion = Math.floor((ahora.getTime() - sesion.iniciadaEn.getTime()) / 1000)
return ctx.prisma.sesionRemota.update({
where: { id: input.sesionId },
data: {
finalizadaEn: ahora,
duracion,
},
})
}),
// Ejecutar comando en equipo
ejecutarComando: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
comando: z.string(),
tipo: z.enum(['powershell', 'cmd', 'bash']).default('powershell'),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// Ejecutar comando via MeshCentral
const meshClient = new MeshCentralClient()
const resultado = await meshClient.runCommand(
dispositivo.meshcentralId,
input.comando,
input.tipo
)
// Registrar en audit log
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'ejecutar_comando',
recurso: 'dispositivo',
detalles: {
comando: input.comando,
tipo: input.tipo,
exito: resultado.success,
},
},
})
return resultado
}),
// Reiniciar equipo
reiniciar: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const meshClient = new MeshCentralClient()
await meshClient.powerAction(dispositivo.meshcentralId, 'restart')
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'reiniciar',
recurso: 'dispositivo',
},
})
return { success: true }
}),
// Apagar equipo
apagar: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const meshClient = new MeshCentralClient()
await meshClient.powerAction(dispositivo.meshcentralId, 'shutdown')
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'apagar',
recurso: 'dispositivo',
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,24 @@
import { router } from '../trpc'
import { authRouter } from './auth.router'
import { clientesRouter } from './clientes.router'
import { equiposRouter } from './equipos.router'
import { celularesRouter } from './celulares.router'
import { redRouter } from './red.router'
import { alertasRouter } from './alertas.router'
import { reportesRouter } from './reportes.router'
import { usuariosRouter } from './usuarios.router'
import { configuracionRouter } from './configuracion.router'
export const appRouter = router({
auth: authRouter,
clientes: clientesRouter,
equipos: equiposRouter,
celulares: celularesRouter,
red: redRouter,
alertas: alertasRouter,
reportes: reportesRouter,
usuarios: usuariosRouter,
configuracion: configuracionRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -0,0 +1,305 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure } from '../trpc'
import { LibreNMSClient } from '@/server/services/librenms/client'
export const redRouter = router({
// Listar dispositivos de red
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: z.enum(['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
const tiposRed = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const
const where = {
tipo: tipo ? { equals: tipo } : { in: tiposRed },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ ip: { contains: search } },
{ mac: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
}
const [dispositivos, total] = await Promise.all([
ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
ubicacion: { select: { id: true, nombre: true } },
},
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.dispositivo.count({ where }),
])
return {
dispositivos,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener dispositivo de red por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
include: {
cliente: true,
ubicacion: true,
alertas: {
where: { estado: 'ACTIVA' },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return dispositivo
}),
// Obtener interfaces de un dispositivo
interfaces: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene LibreNMS',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const librenms = new LibreNMSClient()
return librenms.getDevicePorts(dispositivo.librenmsId)
}),
// Obtener grafico de trafico de una interfaz
trafico: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
portId: z.number(),
periodo: z.enum(['1h', '6h', '24h', '7d', '30d']).default('24h'),
})
)
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene LibreNMS',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const librenms = new LibreNMSClient()
// Calcular rango de tiempo
const now = new Date()
let desde: Date
switch (input.periodo) {
case '1h':
desde = new Date(now.getTime() - 60 * 60 * 1000)
break
case '6h':
desde = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case '24h':
desde = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
desde = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
desde = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
}
return librenms.getPortStats(input.portId, desde, now)
}),
// Obtener topologia de red
topologia: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
// Obtener dispositivos de red del cliente
const dispositivos = await ctx.prisma.dispositivo.findMany({
where: {
...(clienteId ? { clienteId } : {}),
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP'] },
librenmsId: { not: null },
},
select: {
id: true,
nombre: true,
ip: true,
tipo: true,
estado: true,
librenmsId: true,
},
})
if (dispositivos.length === 0) {
return { nodes: [], links: [] }
}
// Obtener enlaces de LibreNMS
const librenms = new LibreNMSClient()
const links = await librenms.getLinks()
// Mapear a nodos y enlaces para visualizacion
const librenmsIdToDevice = new Map(
dispositivos
.filter(d => d.librenmsId !== null)
.map(d => [d.librenmsId!, d])
)
const nodes = dispositivos.map(d => ({
id: d.id,
name: d.nombre,
ip: d.ip,
type: d.tipo,
status: d.estado,
}))
const edges = links
.filter(
(l: { local_device_id: number; remote_device_id: number }) =>
librenmsIdToDevice.has(l.local_device_id) && librenmsIdToDevice.has(l.remote_device_id)
)
.map((l: { local_device_id: number; remote_device_id: number; local_port: string; remote_port: string }) => ({
source: librenmsIdToDevice.get(l.local_device_id)!.id,
target: librenmsIdToDevice.get(l.remote_device_id)!.id,
localPort: l.local_port,
remotePort: l.remote_port,
}))
return { nodes, links: edges }
}),
// Obtener alertas SNMP activas
alertasSNMP: protectedProcedure
.input(z.object({ dispositivoId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const librenms = new LibreNMSClient()
if (input.dispositivoId) {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
return []
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return librenms.getDeviceAlerts(dispositivo.librenmsId)
}
return librenms.getAlerts()
}),
// Obtener datos de NetFlow
netflow: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
periodo: z.enum(['1h', '6h', '24h']).default('1h'),
})
)
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene LibreNMS',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// LibreNMS puede tener integracion con nfsen para netflow
// Por ahora retornamos datos de ejemplo
return {
topTalkers: [],
topProtocols: [],
topPorts: [],
}
}),
// Estadisticas de red por cliente
stats: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const },
...(clienteId ? { clienteId } : {}),
}
const [total, online, offline, alertas] = await Promise.all([
ctx.prisma.dispositivo.count({ where }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'OFFLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ALERTA' } }),
])
return { total, online, offline, alertas }
}),
})

View File

@@ -0,0 +1,389 @@
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
export const reportesRouter = router({
// Reporte de inventario
inventario: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input?.clienteId
const where = {
...(clienteId ? { clienteId } : {}),
...(input?.tipo ? { tipo: input.tipo } : {}),
}
const dispositivos = await ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { nombre: true, codigo: true } },
ubicacion: { select: { nombre: true } },
},
orderBy: [{ cliente: { nombre: 'asc' } }, { tipo: 'asc' }, { nombre: 'asc' }],
})
// Resumen por tipo
const porTipo = await ctx.prisma.dispositivo.groupBy({
by: ['tipo'],
where,
_count: true,
})
// Resumen por cliente
const porCliente = await ctx.prisma.dispositivo.groupBy({
by: ['clienteId'],
where,
_count: true,
})
return {
dispositivos,
resumen: {
total: dispositivos.length,
porTipo: porTipo.map(t => ({ tipo: t.tipo, count: t._count })),
porCliente: porCliente.length,
},
}
}),
// Reporte de uptime
uptime: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
desde: z.date(),
hasta: z.date(),
})
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
...(clienteId ? { clienteId } : {}),
}
// Obtener dispositivos
const dispositivos = await ctx.prisma.dispositivo.findMany({
where,
select: {
id: true,
nombre: true,
tipo: true,
cliente: { select: { nombre: true } },
},
})
// Calcular uptime basado en metricas hourly
const uptimeData = await Promise.all(
dispositivos.map(async (d) => {
const metricas = await ctx.prisma.dispositivoMetricaHourly.count({
where: {
dispositivoId: d.id,
hora: {
gte: input.desde,
lte: input.hasta,
},
},
})
const horasTotales = Math.ceil(
(input.hasta.getTime() - input.desde.getTime()) / (1000 * 60 * 60)
)
const uptimePercent = horasTotales > 0 ? (metricas / horasTotales) * 100 : 0
return {
dispositivo: d.nombre,
tipo: d.tipo,
cliente: d.cliente.nombre,
horasOnline: metricas,
horasTotales,
uptimePercent: Math.min(100, Math.round(uptimePercent * 100) / 100),
}
})
)
return {
periodo: { desde: input.desde, hasta: input.hasta },
dispositivos: uptimeData,
promedioGeneral:
uptimeData.length > 0
? Math.round(
(uptimeData.reduce((sum, d) => sum + d.uptimePercent, 0) /
uptimeData.length) *
100
) / 100
: 0,
}
}),
// Reporte de alertas
alertas: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
desde: z.date(),
hasta: z.date(),
})
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
createdAt: {
gte: input.desde,
lte: input.hasta,
},
...(clienteId ? { clienteId } : {}),
}
const [alertas, porSeveridad, porEstado, porDispositivo] = await Promise.all([
ctx.prisma.alerta.findMany({
where,
include: {
cliente: { select: { nombre: true } },
dispositivo: { select: { nombre: true, tipo: true } },
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.alerta.groupBy({
by: ['severidad'],
where,
_count: true,
}),
ctx.prisma.alerta.groupBy({
by: ['estado'],
where,
_count: true,
}),
ctx.prisma.alerta.groupBy({
by: ['dispositivoId'],
where: {
...where,
dispositivoId: { not: null },
},
_count: true,
orderBy: { _count: { dispositivoId: 'desc' } },
take: 10,
}),
])
// Obtener nombres de dispositivos top
const topDispositivosIds = porDispositivo.map(p => p.dispositivoId).filter(Boolean) as string[]
const topDispositivos = await ctx.prisma.dispositivo.findMany({
where: { id: { in: topDispositivosIds } },
select: { id: true, nombre: true },
})
const dispositivoMap = new Map(topDispositivos.map(d => [d.id, d.nombre]))
return {
periodo: { desde: input.desde, hasta: input.hasta },
total: alertas.length,
alertas,
resumen: {
porSeveridad: porSeveridad.map(s => ({ severidad: s.severidad, count: s._count })),
porEstado: porEstado.map(e => ({ estado: e.estado, count: e._count })),
topDispositivos: porDispositivo.map(d => ({
dispositivo: dispositivoMap.get(d.dispositivoId!) || 'Desconocido',
count: d._count,
})),
},
}
}),
// Reporte de actividad de usuarios
actividad: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
desde: z.date(),
hasta: z.date(),
})
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
createdAt: {
gte: input.desde,
lte: input.hasta,
},
...(clienteId ? {
usuario: { clienteId },
} : {}),
}
const [logs, porAccion, porUsuario, sesiones] = await Promise.all([
ctx.prisma.auditLog.findMany({
where,
include: {
usuario: { select: { nombre: true, email: true } },
dispositivo: { select: { nombre: true } },
},
orderBy: { createdAt: 'desc' },
take: 100,
}),
ctx.prisma.auditLog.groupBy({
by: ['accion'],
where,
_count: true,
orderBy: { _count: { accion: 'desc' } },
}),
ctx.prisma.auditLog.groupBy({
by: ['usuarioId'],
where: {
...where,
usuarioId: { not: null },
},
_count: true,
orderBy: { _count: { usuarioId: 'desc' } },
take: 10,
}),
ctx.prisma.sesionRemota.findMany({
where: {
iniciadaEn: {
gte: input.desde,
lte: input.hasta,
},
},
include: {
usuario: { select: { nombre: true } },
dispositivo: { select: { nombre: true } },
},
orderBy: { iniciadaEn: 'desc' },
}),
])
// Obtener nombres de usuarios top
const topUsuariosIds = porUsuario.map(p => p.usuarioId).filter(Boolean) as string[]
const topUsuarios = await ctx.prisma.usuario.findMany({
where: { id: { in: topUsuariosIds } },
select: { id: true, nombre: true },
})
const usuarioMap = new Map(topUsuarios.map(u => [u.id, u.nombre]))
return {
periodo: { desde: input.desde, hasta: input.hasta },
logs,
sesiones,
resumen: {
totalAcciones: logs.length,
totalSesiones: sesiones.length,
duracionTotalSesiones: sesiones.reduce((sum, s) => sum + (s.duracion || 0), 0),
porAccion: porAccion.map(a => ({ accion: a.accion, count: a._count })),
topUsuarios: porUsuario.map(u => ({
usuario: usuarioMap.get(u.usuarioId!) || 'Desconocido',
count: u._count,
})),
},
}
}),
// Exportar reporte a CSV
exportarCSV: protectedProcedure
.input(
z.object({
tipo: z.enum(['inventario', 'alertas', 'actividad']),
clienteId: z.string().optional(),
desde: z.date().optional(),
hasta: z.date().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
let data: string[][] = []
let headers: string[] = []
switch (input.tipo) {
case 'inventario': {
headers = ['Cliente', 'Tipo', 'Nombre', 'IP', 'SO', 'Serial', 'Estado']
const dispositivos = await ctx.prisma.dispositivo.findMany({
where: clienteId ? { clienteId } : {},
include: { cliente: { select: { nombre: true } } },
})
data = dispositivos.map(d => [
d.cliente.nombre,
d.tipo,
d.nombre,
d.ip || '',
d.sistemaOperativo || '',
d.serial || '',
d.estado,
])
break
}
case 'alertas': {
headers = ['Fecha', 'Cliente', 'Dispositivo', 'Severidad', 'Estado', 'Titulo', 'Mensaje']
const alertas = await ctx.prisma.alerta.findMany({
where: {
...(clienteId ? { clienteId } : {}),
...(input.desde || input.hasta ? {
createdAt: {
...(input.desde ? { gte: input.desde } : {}),
...(input.hasta ? { lte: input.hasta } : {}),
},
} : {}),
},
include: {
cliente: { select: { nombre: true } },
dispositivo: { select: { nombre: true } },
},
})
data = alertas.map(a => [
a.createdAt.toISOString(),
a.cliente.nombre,
a.dispositivo?.nombre || '',
a.severidad,
a.estado,
a.titulo,
a.mensaje,
])
break
}
case 'actividad': {
headers = ['Fecha', 'Usuario', 'Accion', 'Recurso', 'Dispositivo', 'IP']
const logs = await ctx.prisma.auditLog.findMany({
where: {
...(input.desde || input.hasta ? {
createdAt: {
...(input.desde ? { gte: input.desde } : {}),
...(input.hasta ? { lte: input.hasta } : {}),
},
} : {}),
},
include: {
usuario: { select: { nombre: true } },
dispositivo: { select: { nombre: true } },
},
})
data = logs.map(l => [
l.createdAt.toISOString(),
l.usuario?.nombre || '',
l.accion,
l.recurso,
l.dispositivo?.nombre || '',
l.ip || '',
])
break
}
}
// Generar CSV
const csv = [
headers.join(','),
...data.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(',')),
].join('\n')
return {
filename: `reporte-${input.tipo}-${new Date().toISOString().split('T')[0]}.csv`,
content: csv,
contentType: 'text/csv',
}
}),
})

View File

@@ -0,0 +1,322 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import bcrypt from 'bcryptjs'
import { router, protectedProcedure, adminProcedure, superAdminProcedure } from '../trpc'
export const usuariosRouter = router({
// Listar usuarios
list: adminProcedure
.input(
z.object({
clienteId: z.string().optional(),
rol: z.enum(['SUPER_ADMIN', 'ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']).optional(),
activo: z.boolean().optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, rol, activo, search, page = 1, limit = 20 } = input || {}
// Si no es super admin, solo puede ver usuarios de su cliente
const where = {
...(ctx.user.rol !== 'SUPER_ADMIN' && ctx.user.clienteId
? { clienteId: ctx.user.clienteId }
: {}),
...(clienteId ? { clienteId } : {}),
...(rol ? { rol } : {}),
...(activo !== undefined ? { activo } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ email: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
}
const [usuarios, total] = await Promise.all([
ctx.prisma.usuario.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true } },
},
orderBy: { nombre: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.usuario.count({ where }),
])
return {
usuarios: usuarios.map(u => ({
...u,
passwordHash: undefined, // No exponer hash
})),
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener usuario por ID
byId: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { id: input.id },
include: {
cliente: true,
permisos: true,
},
})
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
// Verificar acceso
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return {
...usuario,
passwordHash: undefined,
}
}),
// Crear usuario
create: adminProcedure
.input(
z.object({
email: z.string().email(),
nombre: z.string().min(1),
password: z.string().min(8).optional(),
clienteId: z.string().optional(),
rol: z.enum(['ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']),
telefono: z.string().optional(),
notificarEmail: z.boolean().default(true),
notificarSms: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// Solo super admin puede crear sin clienteId
if (!input.clienteId && ctx.user.rol !== 'SUPER_ADMIN') {
input.clienteId = ctx.user.clienteId!
}
// Verificar email unico
const existe = await ctx.prisma.usuario.findUnique({
where: { email: input.email },
})
if (existe) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Ya existe un usuario con ese email',
})
}
const passwordHash = input.password
? await bcrypt.hash(input.password, 12)
: null
return ctx.prisma.usuario.create({
data: {
email: input.email,
nombre: input.nombre,
passwordHash,
clienteId: input.clienteId,
rol: input.rol,
telefono: input.telefono,
notificarEmail: input.notificarEmail,
notificarSms: input.notificarSms,
},
})
}),
// Actualizar usuario
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().min(1).optional(),
email: z.string().email().optional(),
rol: z.enum(['ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']).optional(),
activo: z.boolean().optional(),
telefono: z.string().optional().nullable(),
notificarEmail: z.boolean().optional(),
notificarSms: z.boolean().optional(),
clienteId: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const usuario = await ctx.prisma.usuario.findUnique({ where: { id } })
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
// Verificar acceso
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// Solo super admin puede cambiar clienteId
if (data.clienteId !== undefined && ctx.user.rol !== 'SUPER_ADMIN') {
delete data.clienteId
}
return ctx.prisma.usuario.update({
where: { id },
data,
})
}),
// Resetear password
resetPassword: adminProcedure
.input(
z.object({
id: z.string(),
newPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({ where: { id: input.id } })
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const passwordHash = await bcrypt.hash(input.newPassword, 12)
await ctx.prisma.usuario.update({
where: { id: input.id },
data: { passwordHash },
})
return { success: true }
}),
// Eliminar usuario
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({ where: { id: input.id } })
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// No permitir auto-eliminacion
if (usuario.id === ctx.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No puedes eliminar tu propio usuario',
})
}
return ctx.prisma.usuario.delete({ where: { id: input.id } })
}),
// Gestionar permisos
permisos: router({
list: adminProcedure
.input(z.object({ usuarioId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.usuarioPermiso.findMany({
where: { usuarioId: input.usuarioId },
})
}),
set: adminProcedure
.input(
z.object({
usuarioId: z.string(),
permisos: z.array(
z.object({
recurso: z.string(),
accion: z.string(),
permitido: z.boolean(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Eliminar permisos existentes
await ctx.prisma.usuarioPermiso.deleteMany({
where: { usuarioId: input.usuarioId },
})
// Crear nuevos permisos
await ctx.prisma.usuarioPermiso.createMany({
data: input.permisos.map(p => ({
usuarioId: input.usuarioId,
recurso: p.recurso,
accion: p.accion,
permitido: p.permitido,
})),
})
return { success: true }
}),
}),
// Crear super admin (solo para setup inicial)
createSuperAdmin: superAdminProcedure
.input(
z.object({
email: z.string().email(),
nombre: z.string().min(1),
password: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
const existe = await ctx.prisma.usuario.findUnique({
where: { email: input.email },
})
if (existe) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Ya existe un usuario con ese email',
})
}
const passwordHash = await bcrypt.hash(input.password, 12)
return ctx.prisma.usuario.create({
data: {
email: input.email,
nombre: input.nombre,
passwordHash,
rol: 'SUPER_ADMIN',
},
})
}),
})

87
src/server/trpc/trpc.ts Normal file
View File

@@ -0,0 +1,87 @@
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import { getSession } from '@/lib/auth'
import prisma from '@/lib/prisma'
import { SessionUser } from '@/types'
export interface Context {
user: SessionUser | null
prisma: typeof prisma
}
export async function createContext(): Promise<Context> {
const user = await getSession()
return {
user,
prisma,
}
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const router = t.router
export const publicProcedure = t.procedure
// Middleware de autenticacion
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const protectedProcedure = t.procedure.use(isAuthenticated)
// Middleware para admin
const isAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
}
if (!['SUPER_ADMIN', 'ADMIN'].includes(ctx.user.rol)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const adminProcedure = t.procedure.use(isAdmin)
// Middleware para super admin
const isSuperAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
}
if (ctx.user.rol !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const superAdminProcedure = t.procedure.use(isSuperAdmin)

100
src/types/index.ts Normal file
View File

@@ -0,0 +1,100 @@
import { TipoDispositivo, EstadoDispositivo, SeveridadAlerta, EstadoAlerta, RolUsuario } from '@prisma/client'
export type { TipoDispositivo, EstadoDispositivo, SeveridadAlerta, EstadoAlerta, RolUsuario }
export interface SessionUser {
id: string
email: string
nombre: string
rol: RolUsuario
clienteId: string | null
meshcentralUser: string | null
}
export interface DashboardStats {
totalDispositivos: number
dispositivosOnline: number
dispositivosOffline: number
dispositivosAlerta: number
alertasActivas: number
alertasCriticas: number
sesionesActivas: number
}
export interface DeviceMetrics {
cpu: number
ram: number
disco: number
temperatura?: number
bateria?: number
}
export interface ChartDataPoint {
timestamp: string
value: number
}
export interface AlertNotification {
id: string
severidad: SeveridadAlerta
titulo: string
mensaje: string
dispositivo?: string
cliente: string
timestamp: Date
}
export interface MeshCentralDevice {
_id: string
name: string
host: string
agent?: {
id: number
caps: number
}
conn: number
pwr: number
ip?: string
osdesc?: string
hardware?: {
identifiers?: {
cpu_name?: string
bios_serial?: string
}
windows?: {
osinfo?: {
Name?: string
Version?: string
}
}
}
}
export interface LibreNMSDevice {
device_id: number
hostname: string
sysName: string
ip: string
status: number
os: string
version: string
hardware: string
serial: string
uptime: number
location: string
}
export interface HeadwindDevice {
id: number
number: string
imei: string
phone: string
model: string
manufacturer: string
osVersion: string
batteryLevel: number
mdmMode: string
lastUpdate: number
lat?: number
lon?: number
}