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

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