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