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

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
# Base de datos PostgreSQL
DATABASE_URL="postgresql://mspmonitor:password@localhost:5432/msp_monitor?schema=public"
# Redis
REDIS_URL="redis://localhost:6379"
# JWT Secret para sesiones
JWT_SECRET="your-super-secret-jwt-key-min-32-chars"
# MeshCentral
MESHCENTRAL_URL="https://mesh.tudominio.com"
MESHCENTRAL_USER="admin"
MESHCENTRAL_PASS="password"
MESHCENTRAL_DOMAIN="default"
# LibreNMS
LIBRENMS_URL="https://librenms.tudominio.com"
LIBRENMS_TOKEN="your-librenms-api-token"
# Headwind MDM
HEADWIND_URL="https://mdm.tudominio.com"
HEADWIND_TOKEN="your-headwind-api-token"
# SMTP para notificaciones
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_USER="tu-email@gmail.com"
SMTP_PASS="tu-app-password"
SMTP_FROM="MSP Monitor <noreply@tudominio.com>"
# Twilio para SMS (opcional)
TWILIO_ACCOUNT_SID=""
TWILIO_AUTH_TOKEN=""
TWILIO_PHONE_NUMBER=""
# URL base de la aplicacion
NEXT_PUBLIC_APP_URL="https://monitor.tudominio.com"
# Modo de desarrollo
NODE_ENV="development"

70
.gitignore vendored Normal file
View File

@@ -0,0 +1,70 @@
# Dependencies
node_modules/
.pnp/
.pnp.js
# Build outputs
.next/
out/
build/
dist/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
*.db
*.sqlite
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
coverage/
.nyc_output/
# Prisma
prisma/migrations/
# Docker volumes
docker/nginx/ssl/
docker/data/
# Backups
backups/
*.sql.gz
# Temporary files
tmp/
temp/
*.tmp
# Package manager locks (keep one)
yarn.lock
pnpm-lock.yaml
# Next.js
.next/
.vercel/
# TypeScript
*.tsbuildinfo
next-env.d.ts

165
README.md Normal file
View File

@@ -0,0 +1,165 @@
# MSP Monitor Dashboard
Dashboard de monitoreo unificado para Proveedores de Servicios Administrados (MSP). Integra MeshCentral, LibreNMS y Headwind MDM en una sola interfaz moderna con tema oscuro cyan/navy.
![Dashboard Preview](docs/images/dashboard-preview.png)
## Caracteristicas
- **Monitoreo Unificado**: Visualiza todos tus dispositivos en una sola interfaz
- **Multi-Tenant**: Gestion de multiples clientes con aislamiento de datos
- **Control Remoto**: Acceso a escritorio, terminal y archivos via MeshCentral
- **MDM Empresarial**: Gestion completa de dispositivos Android con Headwind MDM
- **Monitoreo de Red**: SNMP, NetFlow y alertas via LibreNMS
- **Sistema de Alertas**: Reglas personalizables con notificaciones por email, SMS y webhook
- **Reportes**: Inventario, uptime, alertas y actividad de usuarios
- **API Type-Safe**: Backend con tRPC para comunicacion tipada
## Stack Tecnologico
| Componente | Tecnologia |
|------------|------------|
| Frontend | Next.js 14, React, Tailwind CSS |
| Backend | Next.js API Routes, tRPC |
| Base de Datos | PostgreSQL 16 + Prisma ORM |
| Cache/Queue | Redis 7 + BullMQ |
| Monitoreo PC | MeshCentral |
| Monitoreo Red | LibreNMS |
| MDM Movil | Headwind MDM |
| Proxy/SSL | Nginx + Let's Encrypt |
| Contenedores | Docker + Docker Compose |
## Inicio Rapido
### Requisitos
- Node.js 20+
- Docker y Docker Compose
- Acceso a MeshCentral, LibreNMS y/o Headwind MDM
### Instalacion
```bash
# Clonar repositorio
git clone https://git.consultoria-as.com/msp/msp-monitor-dashboard.git
cd msp-monitor-dashboard
# Ejecutar script de setup
chmod +x scripts/setup.sh
./scripts/setup.sh
```
### Configuracion Rapida
1. Copia el archivo de configuracion:
```bash
cp .env.example .env
```
2. Edita `.env` con tus credenciales:
```env
# Base de datos
DATABASE_URL="postgresql://user:pass@localhost:5432/msp_monitor"
# Integraciones
MESHCENTRAL_URL="https://mesh.tudominio.com"
MESHCENTRAL_USER="admin"
MESHCENTRAL_PASS="password"
LIBRENMS_URL="https://librenms.tudominio.com"
LIBRENMS_TOKEN="tu-token"
HEADWIND_URL="https://mdm.tudominio.com"
HEADWIND_TOKEN="tu-token"
```
3. Inicia los servicios:
```bash
# Desarrollo
npm run dev
# Produccion
docker-compose -f docker/docker-compose.yml up -d
```
4. Accede al dashboard en `http://localhost:3000`
## Estructura del Proyecto
```
msp-monitor-dashboard/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (dashboard)/ # Paginas del dashboard
│ │ └── api/ # API endpoints
│ ├── components/ # Componentes React
│ │ ├── layout/ # Layout components
│ │ └── dashboard/ # Dashboard components
│ ├── server/ # Backend
│ │ ├── trpc/ # tRPC routers
│ │ ├── services/ # Clientes de integracion
│ │ └── jobs/ # Workers de procesamiento
│ ├── lib/ # Utilidades
│ └── types/ # Tipos TypeScript
├── prisma/ # Schema de base de datos
├── docker/ # Configuracion Docker
│ ├── nginx/ # Configuracion Nginx
│ └── docker-compose.yml
├── scripts/ # Scripts de utilidad
└── docs/ # Documentacion
```
## Documentacion
- [Arquitectura](docs/arquitectura/README.md)
- [API Reference](docs/api/README.md)
- [Guia de Instalacion](docs/guias/instalacion.md)
- [Guia de Configuracion](docs/guias/configuracion.md)
## Comandos Utiles
```bash
# Desarrollo
npm run dev # Iniciar servidor de desarrollo
npm run build # Compilar para produccion
npm run lint # Verificar codigo
# Base de datos
npm run db:generate # Generar cliente Prisma
npm run db:push # Aplicar schema a BD
npm run db:studio # Abrir Prisma Studio
# Workers
npm run jobs:start # Iniciar workers de procesamiento
# Docker
docker-compose -f docker/docker-compose.yml up -d # Iniciar servicios
docker-compose -f docker/docker-compose.yml down # Detener servicios
docker-compose -f docker/docker-compose.yml logs -f # Ver logs
# Backups
./scripts/backup-db.sh # Crear backup
./scripts/restore-db.sh /ruta/backup.sql.gz # Restaurar backup
```
## Contribuir
1. Fork el repositorio
2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`)
3. Commit tus cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
5. Crea un Pull Request
## Licencia
Este proyecto es privado y propietario de Consultoria AS.
## Soporte
Para soporte tecnico, contacta a:
- Email: soporte@consultoria-as.com
- Web: https://consultoria-as.com
---
Desarrollado con ❤️ por [Consultoria AS](https://consultoria-as.com)

49
docker/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

148
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,148 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: msp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-mspmonitor}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
POSTGRES_DB: ${POSTGRES_DB:-msp_monitor}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mspmonitor}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- msp-network
# Redis Cache
redis:
image: redis:7-alpine
container_name: msp-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- msp-network
# MSP Monitor Dashboard (Next.js App)
dashboard:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: msp-dashboard
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://${POSTGRES_USER:-mspmonitor}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-msp_monitor}?schema=public
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
- MESHCENTRAL_URL=${MESHCENTRAL_URL}
- MESHCENTRAL_USER=${MESHCENTRAL_USER}
- MESHCENTRAL_PASS=${MESHCENTRAL_PASS}
- MESHCENTRAL_DOMAIN=${MESHCENTRAL_DOMAIN:-default}
- LIBRENMS_URL=${LIBRENMS_URL}
- LIBRENMS_TOKEN=${LIBRENMS_TOKEN}
- HEADWIND_URL=${HEADWIND_URL}
- HEADWIND_TOKEN=${HEADWIND_TOKEN}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM}
- NEXT_PUBLIC_APP_URL=${APP_URL:-http://localhost:3000}
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- msp-network
# Background Worker (Jobs)
worker:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: msp-worker
restart: unless-stopped
command: ["node", "dist/server/jobs/worker.js"]
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://${POSTGRES_USER:-mspmonitor}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-msp_monitor}?schema=public
- REDIS_URL=redis://redis:6379
- MESHCENTRAL_URL=${MESHCENTRAL_URL}
- MESHCENTRAL_USER=${MESHCENTRAL_USER}
- MESHCENTRAL_PASS=${MESHCENTRAL_PASS}
- MESHCENTRAL_DOMAIN=${MESHCENTRAL_DOMAIN:-default}
- LIBRENMS_URL=${LIBRENMS_URL}
- LIBRENMS_TOKEN=${LIBRENMS_TOKEN}
- HEADWIND_URL=${HEADWIND_URL}
- HEADWIND_TOKEN=${HEADWIND_TOKEN}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- msp-network
# Nginx Reverse Proxy
nginx:
image: nginx:alpine
container_name: msp-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- certbot_data:/var/www/certbot:ro
depends_on:
- dashboard
networks:
- msp-network
# Certbot for SSL
certbot:
image: certbot/certbot
container_name: msp-certbot
volumes:
- ./nginx/ssl:/etc/letsencrypt
- certbot_data:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
- msp-network
volumes:
postgres_data:
redis_data:
certbot_data:
networks:
msp-network:
driver: bridge

View File

@@ -0,0 +1,84 @@
# HTTP - Redirect to HTTPS
server {
listen 80;
listen [::]:80;
server_name _;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS - Main site
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name monitor.tudominio.com;
# SSL certificates
ssl_certificate /etc/nginx/ssl/live/monitor.tudominio.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/monitor.tudominio.com/privkey.pem;
# SSL configuration
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# Max upload size
client_max_body_size 100M;
# Proxy to Next.js dashboard
location / {
proxy_pass http://dashboard;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
# API rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_conn conn 10;
proxy_pass http://dashboard;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static files caching
location /_next/static/ {
proxy_pass http://dashboard;
proxy_cache_valid 200 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
# Health check
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
}

53
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,53 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
# Upstream servers
upstream dashboard {
server dashboard:3000;
keepalive 32;
}
# Include site configurations
include /etc/nginx/conf.d/*.conf;
}

516
docs/api/README.md Normal file
View File

@@ -0,0 +1,516 @@
# API Reference
## Vision General
La API de MSP Monitor Dashboard utiliza [tRPC](https://trpc.io/) para comunicacion type-safe entre frontend y backend. Todos los endpoints estan disponibles bajo `/api/trpc`.
## Autenticacion
### Login con Email/Password
```typescript
// POST /api/trpc/auth.login
const result = await trpc.auth.login.mutate({
email: "usuario@example.com",
password: "password123"
})
// Returns: { success: true, user: { id, email, nombre, rol } }
```
### Login con MeshCentral SSO
```typescript
// POST /api/trpc/auth.loginMeshCentral
const result = await trpc.auth.loginMeshCentral.mutate({
username: "meshuser",
token: "mesh-auth-token"
})
```
### Logout
```typescript
// POST /api/trpc/auth.logout
await trpc.auth.logout.mutate()
```
### Usuario Actual
```typescript
// GET /api/trpc/auth.me
const user = await trpc.auth.me.query()
// Returns: { id, email, nombre, rol, cliente, permisos }
```
## Clientes (Tenants)
### Listar Clientes
```typescript
// GET /api/trpc/clientes.list
const { clientes, pagination } = await trpc.clientes.list.query({
search: "term",
activo: true,
page: 1,
limit: 20
})
```
### Obtener Cliente
```typescript
// GET /api/trpc/clientes.byId
const cliente = await trpc.clientes.byId.query({ id: "client-id" })
```
### Crear Cliente
```typescript
// POST /api/trpc/clientes.create
const cliente = await trpc.clientes.create.mutate({
nombre: "Empresa XYZ",
codigo: "XYZ",
email: "contacto@xyz.com",
meshcentralGrupo: "mesh-group-id"
})
```
### Estadisticas del Dashboard
```typescript
// GET /api/trpc/clientes.dashboardStats
const stats = await trpc.clientes.dashboardStats.query({
clienteId: "optional-client-id"
})
// Returns: {
// totalDispositivos, dispositivosOnline, dispositivosOffline,
// dispositivosAlerta, alertasActivas, alertasCriticas
// }
```
## Equipos (PC/Laptop/Servidor)
### Listar Equipos
```typescript
// GET /api/trpc/equipos.list
const { dispositivos, pagination } = await trpc.equipos.list.query({
clienteId: "client-id",
tipo: "SERVIDOR", // PC | LAPTOP | SERVIDOR
estado: "ONLINE", // ONLINE | OFFLINE | ALERTA | MANTENIMIENTO
search: "term",
page: 1,
limit: 20
})
```
### Obtener Equipo
```typescript
// GET /api/trpc/equipos.byId
const equipo = await trpc.equipos.byId.query({ id: "device-id" })
// Incluye: cliente, ubicacion, software, alertas activas
```
### Obtener Metricas
```typescript
// GET /api/trpc/equipos.metricas
const metricas = await trpc.equipos.metricas.query({
dispositivoId: "device-id",
periodo: "24h" // 1h | 6h | 24h | 7d | 30d
})
```
### Iniciar Sesion Remota
```typescript
// POST /api/trpc/equipos.iniciarSesion
const { sesionId, url } = await trpc.equipos.iniciarSesion.mutate({
dispositivoId: "device-id",
tipo: "desktop" // desktop | terminal | files
})
// Redirigir a `url` para abrir conexion
```
### Ejecutar Comando
```typescript
// POST /api/trpc/equipos.ejecutarComando
const resultado = await trpc.equipos.ejecutarComando.mutate({
dispositivoId: "device-id",
comando: "Get-Process",
tipo: "powershell" // powershell | cmd | bash
})
// Returns: { success: true, output: "..." }
```
### Reiniciar/Apagar
```typescript
// POST /api/trpc/equipos.reiniciar
await trpc.equipos.reiniciar.mutate({ dispositivoId: "device-id" })
// POST /api/trpc/equipos.apagar
await trpc.equipos.apagar.mutate({ dispositivoId: "device-id" })
```
## Celulares (MDM)
### Listar Celulares
```typescript
// GET /api/trpc/celulares.list
const { dispositivos, pagination } = await trpc.celulares.list.query({
clienteId: "client-id",
estado: "ONLINE",
search: "term"
})
```
### Obtener Ubicacion
```typescript
// GET /api/trpc/celulares.ubicacion
const { lat, lng, updatedAt } = await trpc.celulares.ubicacion.query({
dispositivoId: "device-id"
})
```
### Solicitar Ubicacion
```typescript
// POST /api/trpc/celulares.solicitarUbicacion
await trpc.celulares.solicitarUbicacion.mutate({
dispositivoId: "device-id"
})
```
### Bloquear/Desbloquear
```typescript
// POST /api/trpc/celulares.bloquear
await trpc.celulares.bloquear.mutate({
dispositivoId: "device-id",
mensaje: "Dispositivo bloqueado por seguridad"
})
// POST /api/trpc/celulares.desbloquear
await trpc.celulares.desbloquear.mutate({
dispositivoId: "device-id"
})
```
### Hacer Sonar
```typescript
// POST /api/trpc/celulares.sonar
await trpc.celulares.sonar.mutate({
dispositivoId: "device-id"
})
```
### Enviar Mensaje
```typescript
// POST /api/trpc/celulares.enviarMensaje
await trpc.celulares.enviarMensaje.mutate({
dispositivoId: "device-id",
mensaje: "Mensaje para el usuario"
})
```
### Borrar Datos (Factory Reset)
```typescript
// POST /api/trpc/celulares.borrarDatos
// CUIDADO: Esta accion es irreversible
await trpc.celulares.borrarDatos.mutate({
dispositivoId: "device-id"
})
```
### Instalar/Desinstalar App
```typescript
// POST /api/trpc/celulares.instalarApp
await trpc.celulares.instalarApp.mutate({
dispositivoId: "device-id",
packageName: "com.example.app"
})
// POST /api/trpc/celulares.desinstalarApp
await trpc.celulares.desinstalarApp.mutate({
dispositivoId: "device-id",
packageName: "com.example.app"
})
```
## Red (SNMP/NetFlow)
### Listar Dispositivos de Red
```typescript
// GET /api/trpc/red.list
const { dispositivos, pagination } = await trpc.red.list.query({
clienteId: "client-id",
tipo: "ROUTER", // ROUTER | SWITCH | FIREWALL | AP | IMPRESORA | OTRO
estado: "ONLINE"
})
```
### Obtener Interfaces
```typescript
// GET /api/trpc/red.interfaces
const interfaces = await trpc.red.interfaces.query({
dispositivoId: "device-id"
})
// Returns: [{ ifName, ifAlias, ifSpeed, ifOperStatus, ifInOctets_rate, ifOutOctets_rate }]
```
### Obtener Trafico
```typescript
// GET /api/trpc/red.trafico
const datos = await trpc.red.trafico.query({
dispositivoId: "device-id",
portId: 123,
periodo: "24h"
})
// Returns: [{ timestamp, in, out }]
```
### Obtener Topologia
```typescript
// GET /api/trpc/red.topologia
const { nodes, links } = await trpc.red.topologia.query({
clienteId: "client-id"
})
// Para visualizacion con D3.js o similar
```
## Alertas
### Listar Alertas
```typescript
// GET /api/trpc/alertas.list
const { alertas, pagination } = await trpc.alertas.list.query({
clienteId: "client-id",
estado: "ACTIVA", // ACTIVA | RECONOCIDA | RESUELTA
severidad: "CRITICAL", // INFO | WARNING | CRITICAL
dispositivoId: "device-id",
desde: new Date("2024-01-01"),
hasta: new Date("2024-01-31")
})
```
### Reconocer Alerta
```typescript
// POST /api/trpc/alertas.reconocer
await trpc.alertas.reconocer.mutate({ id: "alert-id" })
```
### Resolver Alerta
```typescript
// POST /api/trpc/alertas.resolver
await trpc.alertas.resolver.mutate({ id: "alert-id" })
```
### Conteo de Alertas Activas
```typescript
// GET /api/trpc/alertas.conteoActivas
const { total, critical, warning, info } = await trpc.alertas.conteoActivas.query({
clienteId: "client-id"
})
```
## Reglas de Alerta
### Listar Reglas
```typescript
// GET /api/trpc/alertas.reglas.list
const reglas = await trpc.alertas.reglas.list.query({
clienteId: "client-id"
})
```
### Crear Regla
```typescript
// POST /api/trpc/alertas.reglas.create
const regla = await trpc.alertas.reglas.create.mutate({
clienteId: "client-id", // null para regla global
nombre: "CPU Alta",
metrica: "cpu",
operador: ">",
umbral: 90,
duracionMinutos: 5,
severidad: "WARNING",
notificarEmail: true,
notificarSms: false
})
```
## Reportes
### Reporte de Inventario
```typescript
// GET /api/trpc/reportes.inventario
const { dispositivos, resumen } = await trpc.reportes.inventario.query({
clienteId: "client-id",
tipo: "SERVIDOR"
})
```
### Reporte de Uptime
```typescript
// GET /api/trpc/reportes.uptime
const { dispositivos, promedioGeneral } = await trpc.reportes.uptime.query({
clienteId: "client-id",
desde: new Date("2024-01-01"),
hasta: new Date("2024-01-31")
})
```
### Reporte de Alertas
```typescript
// GET /api/trpc/reportes.alertas
const { alertas, resumen } = await trpc.reportes.alertas.query({
clienteId: "client-id",
desde: new Date("2024-01-01"),
hasta: new Date("2024-01-31")
})
```
### Exportar a CSV
```typescript
// POST /api/trpc/reportes.exportarCSV
const { filename, content, contentType } = await trpc.reportes.exportarCSV.mutate({
tipo: "inventario", // inventario | alertas | actividad
clienteId: "client-id"
})
// Descargar el contenido como archivo CSV
```
## Usuarios
### Listar Usuarios
```typescript
// GET /api/trpc/usuarios.list
const { usuarios, pagination } = await trpc.usuarios.list.query({
clienteId: "client-id",
rol: "TECNICO",
activo: true
})
```
### Crear Usuario
```typescript
// POST /api/trpc/usuarios.create
const usuario = await trpc.usuarios.create.mutate({
email: "nuevo@example.com",
nombre: "Nuevo Usuario",
password: "password123",
clienteId: "client-id",
rol: "TECNICO"
})
```
### Resetear Password
```typescript
// POST /api/trpc/usuarios.resetPassword
await trpc.usuarios.resetPassword.mutate({
id: "user-id",
newPassword: "newpassword123"
})
```
## Configuracion
### Obtener Configuracion
```typescript
// GET /api/trpc/configuracion.get
const config = await trpc.configuracion.get.query({
clave: "smtp_host"
})
```
### Establecer Configuracion
```typescript
// POST /api/trpc/configuracion.set
await trpc.configuracion.set.mutate({
clave: "smtp_host",
valor: "smtp.gmail.com",
tipo: "string",
categoria: "notificacion"
})
```
### Estado de Integraciones
```typescript
// GET /api/trpc/configuracion.integraciones.status
const { meshcentral, librenms, headwind } = await trpc.configuracion.integraciones.status.query()
// Returns: { configurado: boolean, url?: string }
```
### Configurar MeshCentral
```typescript
// POST /api/trpc/configuracion.integraciones.setMeshCentral
await trpc.configuracion.integraciones.setMeshCentral.mutate({
url: "https://mesh.tudominio.com",
user: "admin",
password: "password",
domain: "default"
})
```
## Codigos de Error
| Codigo | Descripcion |
|--------|-------------|
| UNAUTHORIZED | No autenticado |
| FORBIDDEN | Sin permisos |
| NOT_FOUND | Recurso no encontrado |
| CONFLICT | Conflicto (ej: email duplicado) |
| BAD_REQUEST | Request invalido |
| INTERNAL_SERVER_ERROR | Error del servidor |
## Rate Limiting
- API general: 100 requests/minuto
- Autenticacion: 10 requests/minuto
- Acciones sensibles (wipe, commands): 5 requests/minuto
## Webhooks
Las alertas pueden enviar webhooks a URLs configuradas:
```json
{
"id": "alert-id",
"severidad": "CRITICAL",
"titulo": "Servidor offline",
"mensaje": "El servidor SRV-01 no responde",
"dispositivo": "SRV-01",
"cliente": "Cliente A",
"timestamp": "2024-01-15T10:30:00Z"
}
```

321
docs/arquitectura/README.md Normal file
View File

@@ -0,0 +1,321 @@
# Arquitectura del Sistema
## Vision General
MSP Monitor Dashboard es una aplicacion web moderna construida con una arquitectura monolitica modular, diseñada para ejecutarse en una unica VM de Proxmox mientras mantiene una clara separacion de responsabilidades.
```
┌─────────────────────────────────────────────────────────────────┐
│ NGINX (Proxy) │
│ SSL/TLS + Rate Limiting │
└─────────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────────┐
│ Next.js Dashboard │ │ Background Workers │
│ (Frontend + API Routes) │ │ (BullMQ Jobs) │
└───────────────────────────┘ └───────────────────────────────┘
│ │
└───────────────┬───────────────┘
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ PostgreSQL │ │ Redis │ │ External │
│ (Primary) │ │ (Cache/Queue) │ │ APIs │
└───────────────┘ └───────────────┘ └───────────────┘
┌───────────────────────────────┼───────────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ MeshCentral │ │ LibreNMS │ │ Headwind MDM │
│ (PC/Laptop) │ │ (Network) │ │ (Mobile) │
└───────────────┘ └───────────────┘ └───────────────┘
```
## Componentes Principales
### 1. Frontend (Next.js + React)
**Ubicacion**: `src/app/`, `src/components/`
El frontend utiliza Next.js 14 con App Router, proporcionando:
- **Server Components**: Para renderizado del lado del servidor
- **Client Components**: Para interactividad en el navegador
- **Tailwind CSS**: Sistema de diseño con tema oscuro cyan/navy
- **React Query**: Cache y sincronizacion de estado del servidor
**Estructura de paginas**:
```
src/app/
├── (dashboard)/ # Grupo de rutas autenticadas
│ ├── layout.tsx # Layout con sidebar y header
│ ├── page.tsx # Dashboard principal
│ ├── equipos/ # Gestion de PCs/laptops/servidores
│ ├── celulares/ # Gestion de dispositivos moviles
│ ├── red/ # Gestion de dispositivos de red
│ ├── alertas/ # Centro de alertas
│ ├── reportes/ # Generacion de reportes
│ └── configuracion/ # Configuracion del sistema
└── api/ # API Routes
└── trpc/ # Endpoint tRPC
```
### 2. Backend (tRPC + Prisma)
**Ubicacion**: `src/server/trpc/`
El backend utiliza tRPC para APIs type-safe con validacion Zod:
```typescript
// Ejemplo de router
export const equiposRouter = router({
list: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.dispositivo.findMany({
where: input.clienteId ? { clienteId: input.clienteId } : {},
})
}),
})
```
**Routers disponibles**:
- `auth` - Autenticacion y sesiones
- `clientes` - Gestion de clientes/tenants
- `equipos` - PCs, laptops, servidores
- `celulares` - Dispositivos moviles
- `red` - Dispositivos de red
- `alertas` - Sistema de alertas
- `reportes` - Generacion de reportes
- `usuarios` - Gestion de usuarios
- `configuracion` - Configuracion del sistema
### 3. Servicios de Integracion
**Ubicacion**: `src/server/services/`
Clientes para comunicacion con sistemas externos:
```
services/
├── meshcentral/
│ └── client.ts # Cliente API REST + WebSocket
├── librenms/
│ └── client.ts # Cliente API REST
└── headwind/
└── client.ts # Cliente API REST
```
Cada cliente proporciona:
- Autenticacion automatica
- Manejo de errores
- Tipado TypeScript
- Cache de conexiones
### 4. Sistema de Jobs (BullMQ)
**Ubicacion**: `src/server/jobs/`
Procesamiento asincronico con BullMQ y Redis:
```
jobs/
├── queue.ts # Configuracion de colas
├── worker.ts # Worker principal
├── sync-meshcentral.job.ts
├── sync-librenms.job.ts
├── sync-headwind.job.ts
├── alert-processor.job.ts
├── notification.job.ts
└── maintenance.job.ts
```
**Colas**:
| Cola | Proposito | Frecuencia |
|------|-----------|------------|
| sync | Sincronizacion con plataformas | Cada 5 min |
| alerts | Procesamiento de alertas | Bajo demanda |
| notifications | Envio de notificaciones | Bajo demanda |
| maintenance | Tareas de mantenimiento | Diario/Horario |
### 5. Base de Datos (PostgreSQL + Prisma)
**Ubicacion**: `prisma/schema.prisma`
Modelo de datos multi-tenant:
```
┌─────────────┐ ┌─────────────────┐
│ Cliente │────<│ Dispositivo │
└─────────────┘ └─────────────────┘
│ │
│ ┌───────┴───────┐
│ ▼ ▼
│ ┌───────────────┐ ┌───────────────┐
│ │ Metrica │ │ Software │
│ └───────────────┘ └───────────────┘
├───<│ Usuario │
└───<│ Alerta │
```
**Tablas principales**:
- `clientes` - Tenants del sistema
- `dispositivos` - Todos los tipos de dispositivos
- `alertas` - Alertas y eventos
- `usuarios` - Usuarios y permisos
- `configuracion` - Settings del sistema
## Flujo de Datos
### 1. Sincronizacion de Dispositivos
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Plataforma │────>│ Worker │────>│ PostgreSQL │
│ Externa │ │ (BullMQ) │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
▲ │
│ ▼
│ ┌─────────────┐
│ │ Redis │
│ │ (Cache) │
│ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
└────────────│ Alerta │
│ Processor │
└─────────────┘
```
### 2. Consulta de Dispositivos
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │────>│ Next.js │────>│ tRPC │
│ │<────│ (SSR) │<────│ Router │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│ Prisma │
│ Client │
└─────────────┘
┌───────────────────┴───────────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Redis │ │ PostgreSQL │
│ (Cache) │ │ │
└─────────────┘ └─────────────┘
```
### 3. Sesion Remota
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Usuario │────>│ Dashboard │────>│ MeshCentral │
│ │ │ │ │ (iframe) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Audit Log │<───────────┘
│ └─────────────┘
┌─────────────────────────────────────────────────────┐
│ WebSocket (MeshCentral) │
│ Desktop / Terminal / Files │
└─────────────────────────────────────────────────────┘
```
## Seguridad
### Autenticacion
- **MeshCentral SSO**: Integracion con autenticacion de MeshCentral
- **JWT Sessions**: Tokens firmados con expiracion de 24h
- **Password Hashing**: bcrypt con salt factor 12
### Autorizacion
```typescript
// Roles jerarquicos
enum RolUsuario {
SUPER_ADMIN, // Acceso total
ADMIN, // Admin de cliente
TECNICO, // Operaciones
CLIENTE, // Solo lectura
VIEWER // Vista limitada
}
```
### Aislamiento de Datos
- Cada consulta filtra por `clienteId`
- Middleware verifica permisos en cada request
- Audit log de todas las acciones sensibles
## Escalabilidad
### Horizontal (futuro)
```
┌─────────────┐
│ HAProxy │
│ (LB) │
└─────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Node 1 │ │ Node 2 │ │ Node 3 │
│ (Next.js) │ │ (Next.js) │ │ (Worker) │
└───────────┘ └───────────┘ └───────────┘
│ │ │
└───────────────┼───────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ PostgreSQL│ │ Redis │ │ Redis │
│ Primary │ │ Primary │ │ Replica │
└───────────┘ └───────────┘ └───────────┘
```
### Vertical (actual)
- **CPU**: Escalable segun carga
- **RAM**: Minimo 4GB recomendado
- **Disco**: SSD para BD, puede usar storage separado para backups
## Monitoreo del Sistema
### Health Checks
```bash
# Endpoints de health
GET /health # Estado general
GET /api/health # Estado de API
```
### Metricas
- CPU/RAM/Disco del servidor
- Latencia de BD
- Tamano de colas
- Errores de sincronizacion
### Logs
```bash
# Ver logs de contenedores
docker-compose logs -f dashboard
docker-compose logs -f worker
```

332
docs/guias/configuracion.md Normal file
View File

@@ -0,0 +1,332 @@
# Guia de Configuracion
## Configuracion General
### Variables de Entorno
El archivo `.env` contiene toda la configuracion del sistema:
```env
# ==================== BASE DE DATOS ====================
# URL de conexion a PostgreSQL
DATABASE_URL="postgresql://usuario:password@host:5432/database?schema=public"
# Credenciales para Docker
POSTGRES_USER=mspmonitor
POSTGRES_PASSWORD=password-seguro
POSTGRES_DB=msp_monitor
# ==================== CACHE Y COLAS ====================
# URL de conexion a Redis
REDIS_URL="redis://localhost:6379"
# ==================== SEGURIDAD ====================
# Clave secreta para JWT (minimo 32 caracteres)
JWT_SECRET="clave-muy-segura-de-al-menos-32-caracteres"
# ==================== INTEGRACIONES ====================
# MeshCentral
MESHCENTRAL_URL="https://mesh.tudominio.com"
MESHCENTRAL_USER="admin"
MESHCENTRAL_PASS="password"
MESHCENTRAL_DOMAIN="default"
# LibreNMS
LIBRENMS_URL="https://librenms.tudominio.com"
LIBRENMS_TOKEN="tu-token-api"
# Headwind MDM
HEADWIND_URL="https://mdm.tudominio.com"
HEADWIND_TOKEN="tu-token-api"
# ==================== NOTIFICACIONES ====================
# SMTP
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_USER="tu-email@gmail.com"
SMTP_PASS="tu-app-password"
SMTP_FROM="MSP Monitor <noreply@tudominio.com>"
# Twilio (opcional)
TWILIO_ACCOUNT_SID=""
TWILIO_AUTH_TOKEN=""
TWILIO_PHONE_NUMBER=""
# ==================== APLICACION ====================
# URL publica de la aplicacion
NEXT_PUBLIC_APP_URL="https://monitor.tudominio.com"
# Entorno
NODE_ENV="production"
```
## Configuracion de Integraciones
### MeshCentral
1. **Crear usuario API en MeshCentral**:
- Acceder a MeshCentral como admin
- Ir a "My Account" > "Security" > "Create Login Token"
- O crear usuario dedicado con permisos de API
2. **Configurar en el dashboard**:
- Ir a Configuracion > Integraciones
- Ingresar URL, usuario y password
- Probar conexion
3. **Mapear grupos**:
- Cada cliente puede tener un grupo de MeshCentral asignado
- Los dispositivos del grupo se sincronizaran automaticamente
### LibreNMS
1. **Crear API Token en LibreNMS**:
- Ir a "Settings" > "API" > "API Settings"
- Crear nuevo token con permisos de lectura
2. **Configurar en el dashboard**:
- Ir a Configuracion > Integraciones
- Ingresar URL y token
- Probar conexion
3. **Mapear grupos de dispositivos**:
- Crear grupos en LibreNMS para cada cliente
- Asignar el grupo al cliente en el dashboard
### Headwind MDM
1. **Obtener API Token**:
- Acceder a panel de admin de Headwind
- Ir a configuracion de API
- Copiar token de acceso
2. **Configurar en el dashboard**:
- Ir a Configuracion > Integraciones
- Ingresar URL y token
- Probar conexion
## Configuracion de Notificaciones
### Email (SMTP)
Para Gmail:
1. Habilitar autenticacion de dos factores
2. Crear "App Password" en configuracion de seguridad
3. Usar el app password en `SMTP_PASS`
```env
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_USER="tu-cuenta@gmail.com"
SMTP_PASS="xxxx-xxxx-xxxx-xxxx" # App password
SMTP_FROM="MSP Monitor <noreply@tudominio.com>"
```
Para otros proveedores:
| Proveedor | Host | Puerto |
|-----------|------|--------|
| Gmail | smtp.gmail.com | 587 |
| Office 365 | smtp.office365.com | 587 |
| SendGrid | smtp.sendgrid.net | 587 |
| Mailgun | smtp.mailgun.org | 587 |
### SMS (Twilio)
1. Crear cuenta en Twilio
2. Obtener Account SID y Auth Token
3. Comprar numero de telefono
4. Configurar en `.env`:
```env
TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_PHONE_NUMBER="+1234567890"
```
### Webhooks
Los webhooks se configuran por regla de alerta:
1. Crear regla de alerta
2. Habilitar "Notificar por Webhook"
3. Ingresar URL del endpoint
Formato del payload:
```json
{
"id": "alert-id",
"severidad": "CRITICAL",
"titulo": "Titulo de la alerta",
"mensaje": "Descripcion detallada",
"dispositivo": "Nombre del dispositivo",
"cliente": "Nombre del cliente",
"timestamp": "2024-01-15T10:30:00Z"
}
```
## Configuracion de Usuarios y Permisos
### Roles de Usuario
| Rol | Descripcion | Permisos |
|-----|-------------|----------|
| SUPER_ADMIN | Administrador global | Todo |
| ADMIN | Admin de cliente | Gestion de su cliente |
| TECNICO | Soporte tecnico | Operaciones y monitoreo |
| CLIENTE | Usuario cliente | Solo lectura |
| VIEWER | Vista limitada | Dashboard basico |
### Crear Usuario
1. Ir a Configuracion > Usuarios
2. Click en "Nuevo Usuario"
3. Completar:
- Email
- Nombre
- Rol
- Cliente (opcional)
- Password (o usar SSO de MeshCentral)
### Permisos Granulares
Ademas del rol, se pueden asignar permisos especificos:
```
recursos: dispositivos, alertas, reportes, configuracion, usuarios
acciones: read, write, delete, execute
```
## Configuracion de Alertas
### Reglas Predefinidas
El sistema incluye reglas por defecto:
| Regla | Metrica | Umbral | Severidad |
|-------|---------|--------|-----------|
| CPU Alta | cpu | > 90% | WARNING |
| CPU Critica | cpu | > 95% | CRITICAL |
| RAM Alta | ram | > 85% | WARNING |
| Disco Lleno | disco | > 90% | WARNING |
| Temperatura Alta | temperatura | > 80 | WARNING |
| Bateria Baja | bateria | < 15 | WARNING |
### Crear Regla Personalizada
1. Ir a Alertas > Reglas
2. Click en "Nueva Regla"
3. Configurar:
- Nombre descriptivo
- Tipo de dispositivo (opcional)
- Metrica a monitorear
- Condicion (>, <, >=, <=, ==)
- Valor umbral
- Duracion minima (evitar falsos positivos)
- Severidad
- Canales de notificacion
## Configuracion de Backups
### Backup Automatico
Agregar al crontab del servidor:
```bash
# Backup diario a las 2am
0 2 * * * /opt/msp-monitor/scripts/backup-db.sh >> /var/log/msp-backup.log 2>&1
```
### Configuracion de Backup
Variables en `.env`:
```env
# Directorio de backups
BACKUP_DIR="/backups"
# Dias de retencion
RETENTION_DAYS=30
# S3 (opcional)
S3_BUCKET="mi-bucket-backups"
AWS_ACCESS_KEY_ID="..."
AWS_SECRET_ACCESS_KEY="..."
```
### Restaurar Backup
```bash
./scripts/restore-db.sh /backups/msp_monitor_20240115_020000.sql.gz
```
## Configuracion de SSL
### Renovacion Automatica
El contenedor de Certbot renueva automaticamente. Verificar:
```bash
docker compose -f docker/docker-compose.yml logs certbot
```
### Certificado Manual
1. Copiar certificados a `docker/nginx/ssl/`
2. Actualizar paths en `docker/nginx/conf.d/default.conf`
3. Reiniciar Nginx: `docker compose restart nginx`
## Configuracion de Logs
### Niveles de Log
En `.env`:
```env
# Nivel de log: debug, info, warn, error
LOG_LEVEL="info"
```
### Rotacion de Logs
Docker maneja la rotacion. Configurar en `/etc/docker/daemon.json`:
```json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
```
## Personalizacion
### Tema Visual
Editar `tailwind.config.ts` para cambiar colores:
```typescript
colors: {
primary: {
500: '#06b6d4', // Color principal
// ...
},
// ...
}
```
### Logo
Reemplazar archivo en `public/logo.png`
### Titulo
Editar `src/app/layout.tsx`:
```typescript
export const metadata: Metadata = {
title: 'Mi Dashboard MSP',
// ...
}
```

359
docs/guias/instalacion.md Normal file
View File

@@ -0,0 +1,359 @@
# Guia de Instalacion
## Requisitos del Sistema
### Hardware Minimo
| Componente | Minimo | Recomendado |
|------------|--------|-------------|
| CPU | 2 cores | 4+ cores |
| RAM | 4 GB | 8+ GB |
| Disco | 40 GB SSD | 100+ GB SSD |
| Red | 100 Mbps | 1 Gbps |
### Software
- **Sistema Operativo**: Ubuntu 22.04 LTS o Debian 12
- **Docker**: 24.0+
- **Docker Compose**: 2.20+
- **Node.js**: 20.x (solo para desarrollo)
### Requisitos de Red
- Puerto 80 (HTTP)
- Puerto 443 (HTTPS)
- Puerto 5432 (PostgreSQL, solo interno)
- Puerto 6379 (Redis, solo interno)
- Acceso a MeshCentral, LibreNMS y/o Headwind MDM
## Instalacion en Produccion
### 1. Preparar el Servidor
```bash
# Actualizar sistema
sudo apt update && sudo apt upgrade -y
# Instalar dependencias
sudo apt install -y curl git
# Instalar Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Agregar usuario al grupo docker
sudo usermod -aG docker $USER
newgrp docker
# Instalar Docker Compose
sudo apt install -y docker-compose-plugin
```
### 2. Clonar el Repositorio
```bash
# Crear directorio
sudo mkdir -p /opt/msp-monitor
cd /opt/msp-monitor
# Clonar repositorio
git clone https://git.consultoria-as.com/msp/msp-monitor-dashboard.git .
# Configurar permisos
sudo chown -R $USER:$USER /opt/msp-monitor
```
### 3. Configurar Variables de Entorno
```bash
# Copiar archivo de ejemplo
cp .env.example .env
# Editar configuracion
nano .env
```
Configuracion minima requerida:
```env
# Seguridad - CAMBIAR ESTOS VALORES
POSTGRES_PASSWORD=<password-seguro>
JWT_SECRET=<clave-jwt-minimo-32-caracteres>
# MeshCentral (opcional si no se usa)
MESHCENTRAL_URL=https://mesh.tudominio.com
MESHCENTRAL_USER=admin
MESHCENTRAL_PASS=password
MESHCENTRAL_DOMAIN=default
# LibreNMS (opcional si no se usa)
LIBRENMS_URL=https://librenms.tudominio.com
LIBRENMS_TOKEN=tu-api-token
# Headwind MDM (opcional si no se usa)
HEADWIND_URL=https://mdm.tudominio.com
HEADWIND_TOKEN=tu-api-token
# Email (para notificaciones)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=tu-email@gmail.com
SMTP_PASS=tu-app-password
SMTP_FROM=MSP Monitor <noreply@tudominio.com>
# URL de la aplicacion
APP_URL=https://monitor.tudominio.com
```
### 4. Configurar SSL
#### Opcion A: Certificado Let's Encrypt (recomendado)
```bash
# Crear directorios
mkdir -p docker/nginx/ssl
# Iniciar Nginx sin SSL primero
docker compose -f docker/docker-compose.yml up -d nginx
# Obtener certificado
docker compose -f docker/docker-compose.yml run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
-d monitor.tudominio.com \
--email tu-email@tudominio.com \
--agree-tos \
--no-eff-email
# Reiniciar con SSL
docker compose -f docker/docker-compose.yml restart nginx
```
#### Opcion B: Certificado Propio
```bash
# Copiar certificados
cp /ruta/certificado.crt docker/nginx/ssl/live/monitor.tudominio.com/fullchain.pem
cp /ruta/certificado.key docker/nginx/ssl/live/monitor.tudominio.com/privkey.pem
```
### 5. Configurar Nginx
Editar `docker/nginx/conf.d/default.conf`:
```nginx
server {
listen 443 ssl http2;
server_name monitor.tudominio.com; # Cambiar por tu dominio
ssl_certificate /etc/nginx/ssl/live/monitor.tudominio.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/monitor.tudominio.com/privkey.pem;
# ... resto de configuracion
}
```
### 6. Iniciar Servicios
```bash
# Construir imagenes
docker compose -f docker/docker-compose.yml build
# Iniciar servicios
docker compose -f docker/docker-compose.yml up -d
# Verificar estado
docker compose -f docker/docker-compose.yml ps
```
### 7. Aplicar Migraciones
```bash
# Ejecutar migraciones
docker compose -f docker/docker-compose.yml exec dashboard npx prisma db push
# Verificar conexion a BD
docker compose -f docker/docker-compose.yml exec dashboard npx prisma studio
```
### 8. Crear Usuario Administrador
```bash
# Conectar a la BD
docker compose -f docker/docker-compose.yml exec postgres psql -U mspmonitor -d msp_monitor
# Crear usuario (dentro de psql)
INSERT INTO usuarios (id, email, nombre, password_hash, rol, activo, created_at, updated_at)
VALUES (
gen_random_uuid(),
'admin@tudominio.com',
'Administrador',
'$2a$12$hash-del-password', -- Generar con bcrypt
'SUPER_ADMIN',
true,
NOW(),
NOW()
);
```
O usando el script de setup interactivo:
```bash
./scripts/setup.sh
```
### 9. Verificar Instalacion
1. Acceder a `https://monitor.tudominio.com`
2. Iniciar sesion con las credenciales creadas
3. Verificar conexion a MeshCentral, LibreNMS, Headwind
4. Revisar logs: `docker compose -f docker/docker-compose.yml logs -f`
## Instalacion en Desarrollo
### 1. Requisitos
- Node.js 20+
- npm o yarn
- Docker (para PostgreSQL y Redis)
### 2. Configurar Entorno
```bash
# Clonar repositorio
git clone https://git.consultoria-as.com/msp/msp-monitor-dashboard.git
cd msp-monitor-dashboard
# Instalar dependencias
npm install
# Configurar variables
cp .env.example .env
# Editar .env con configuracion local
```
### 3. Iniciar Servicios de Base de Datos
```bash
# Solo PostgreSQL y Redis
docker compose -f docker/docker-compose.yml up -d postgres redis
```
### 4. Configurar Base de Datos
```bash
# Generar cliente Prisma
npm run db:generate
# Aplicar schema
npm run db:push
# (Opcional) Abrir Prisma Studio
npm run db:studio
```
### 5. Iniciar Desarrollo
```bash
# Iniciar servidor de desarrollo
npm run dev
# En otra terminal, iniciar workers (opcional)
npm run jobs:start
```
Acceder a `http://localhost:3000`
## Actualizacion
### Actualizacion en Produccion
```bash
cd /opt/msp-monitor
# Detener servicios
docker compose -f docker/docker-compose.yml down
# Actualizar codigo
git pull origin main
# Reconstruir imagenes
docker compose -f docker/docker-compose.yml build
# Iniciar servicios
docker compose -f docker/docker-compose.yml up -d
# Aplicar migraciones si hay cambios de BD
docker compose -f docker/docker-compose.yml exec dashboard npx prisma db push
```
### Actualizacion en Desarrollo
```bash
# Actualizar codigo
git pull origin main
# Actualizar dependencias
npm install
# Regenerar cliente Prisma
npm run db:generate
# Aplicar migraciones
npm run db:push
```
## Solucion de Problemas
### Error de Conexion a Base de Datos
```bash
# Verificar que PostgreSQL esta corriendo
docker compose -f docker/docker-compose.yml ps postgres
# Ver logs de PostgreSQL
docker compose -f docker/docker-compose.yml logs postgres
# Verificar conectividad
docker compose -f docker/docker-compose.yml exec postgres pg_isready
```
### Error de Conexion a Redis
```bash
# Verificar que Redis esta corriendo
docker compose -f docker/docker-compose.yml ps redis
# Probar conexion
docker compose -f docker/docker-compose.yml exec redis redis-cli ping
```
### Dashboard no Carga
```bash
# Ver logs del dashboard
docker compose -f docker/docker-compose.yml logs dashboard
# Verificar variables de entorno
docker compose -f docker/docker-compose.yml exec dashboard env
# Reiniciar servicio
docker compose -f docker/docker-compose.yml restart dashboard
```
### Workers no Procesan Jobs
```bash
# Ver logs del worker
docker compose -f docker/docker-compose.yml logs worker
# Verificar colas en Redis
docker compose -f docker/docker-compose.yml exec redis redis-cli KEYS "bull:*"
```
## Proximos Pasos
- [Configuracion del Sistema](configuracion.md)
- [Integracion con MeshCentral](../integraciones/meshcentral.md)
- [Integracion con LibreNMS](../integraciones/librenms.md)
- [Integracion con Headwind MDM](../integraciones/headwind.md)

17
next.config.js Normal file
View File

@@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
}
module.exports = nextConfig

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "msp-monitor-dashboard",
"version": "1.0.0",
"description": "Dashboard de monitoreo MSP - MeshCentral, LibreNMS, Headwind MDM",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"jobs:start": "tsx src/server/jobs/worker.ts"
},
"dependencies": {
"@prisma/client": "^5.10.0",
"@tanstack/react-query": "^5.24.0",
"@trpc/client": "^10.45.0",
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.0",
"clsx": "^2.1.0",
"date-fns": "^3.3.0",
"ioredis": "^5.3.2",
"jose": "^5.2.0",
"lucide-react": "^0.338.0",
"next": "14.1.0",
"nodemailer": "^6.9.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.2.1",
"twilio": "^4.21.0",
"ws": "^8.16.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.11.0",
"@types/nodemailer": "^6.4.14",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/ws": "^8.5.10",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"prisma": "^5.10.0",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=20.0.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

361
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,361 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== CLIENTES ====================
model Cliente {
id String @id @default(cuid())
nombre String
codigo String @unique
email String?
telefono String?
activo Boolean @default(true)
notas String?
meshcentralGrupo String? @map("meshcentral_grupo")
librenmsGrupo Int? @map("librenms_grupo")
headwindGrupo Int? @map("headwind_grupo")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
ubicaciones ClienteUbicacion[]
dispositivos Dispositivo[]
usuarios Usuario[]
alertas Alerta[]
alertaReglas AlertaRegla[]
@@map("clientes")
}
model ClienteUbicacion {
id String @id @default(cuid())
clienteId String @map("cliente_id")
nombre String
direccion String?
ciudad String?
estado String?
pais String @default("Mexico")
codigoPostal String? @map("codigo_postal")
latitud Float?
longitud Float?
principal Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
dispositivos Dispositivo[]
@@map("cliente_ubicaciones")
}
// ==================== DISPOSITIVOS ====================
enum TipoDispositivo {
PC
LAPTOP
SERVIDOR
CELULAR
TABLET
ROUTER
SWITCH
FIREWALL
AP
IMPRESORA
OTRO
}
enum EstadoDispositivo {
ONLINE
OFFLINE
ALERTA
MANTENIMIENTO
DESCONOCIDO
}
model Dispositivo {
id String @id @default(cuid())
clienteId String @map("cliente_id")
ubicacionId String? @map("ubicacion_id")
tipo TipoDispositivo
nombre String
descripcion String?
estado EstadoDispositivo @default(DESCONOCIDO)
// IDs externos
meshcentralId String? @unique @map("meshcentral_id")
librenmsId Int? @unique @map("librenms_id")
headwindId Int? @unique @map("headwind_id")
// Info general
ip String?
mac String?
sistemaOperativo String? @map("sistema_operativo")
versionSO String? @map("version_so")
fabricante String?
modelo String?
serial String?
// Para equipos de computo
cpu String?
ram Int? // En MB
disco Int? // En GB
// Para celulares
imei String?
numeroTelefono String? @map("numero_telefono")
operador String?
// Para red
firmware String?
snmpCommunity String? @map("snmp_community")
// Metricas actuales
cpuUsage Float? @map("cpu_usage")
ramUsage Float? @map("ram_usage")
discoUsage Float? @map("disco_usage")
temperatura Float?
bateria Int?
// Ubicacion GPS (celulares)
latitud Float?
longitud Float?
gpsUpdatedAt DateTime? @map("gps_updated_at")
// Timestamps
lastSeen DateTime? @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
ubicacion ClienteUbicacion? @relation(fields: [ubicacionId], references: [id])
software DispositivoSoftware[]
metricas DispositivoMetrica[]
metricasHourly DispositivoMetricaHourly[]
alertas Alerta[]
sesiones SesionRemota[]
auditLogs AuditLog[]
@@index([clienteId])
@@index([tipo])
@@index([estado])
@@map("dispositivos")
}
model DispositivoSoftware {
id String @id @default(cuid())
dispositivoId String @map("dispositivo_id")
nombre String
version String?
editor String?
instaladoEn DateTime? @map("instalado_en")
tamanio Int? // En MB
createdAt DateTime @default(now()) @map("created_at")
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id], onDelete: Cascade)
@@unique([dispositivoId, nombre, version])
@@map("dispositivo_software")
}
model DispositivoMetrica {
id String @id @default(cuid())
dispositivoId String @map("dispositivo_id")
timestamp DateTime @default(now())
cpuUsage Float? @map("cpu_usage")
ramUsage Float? @map("ram_usage")
discoUsage Float? @map("disco_usage")
temperatura Float?
bateria Int?
redIn BigInt? @map("red_in") // bytes
redOut BigInt? @map("red_out") // bytes
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id], onDelete: Cascade)
@@index([dispositivoId, timestamp])
@@map("dispositivo_metricas")
}
model DispositivoMetricaHourly {
id String @id @default(cuid())
dispositivoId String @map("dispositivo_id")
hora DateTime
cpuAvg Float? @map("cpu_avg")
cpuMax Float? @map("cpu_max")
ramAvg Float? @map("ram_avg")
ramMax Float? @map("ram_max")
discoAvg Float? @map("disco_avg")
tempAvg Float? @map("temp_avg")
tempMax Float? @map("temp_max")
redInTotal BigInt? @map("red_in_total")
redOutTotal BigInt? @map("red_out_total")
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id], onDelete: Cascade)
@@unique([dispositivoId, hora])
@@map("dispositivo_metricas_hourly")
}
// ==================== ALERTAS ====================
enum SeveridadAlerta {
INFO
WARNING
CRITICAL
}
enum EstadoAlerta {
ACTIVA
RECONOCIDA
RESUELTA
}
model Alerta {
id String @id @default(cuid())
clienteId String @map("cliente_id")
dispositivoId String? @map("dispositivo_id")
reglaId String? @map("regla_id")
severidad SeveridadAlerta
estado EstadoAlerta @default(ACTIVA)
titulo String
mensaje String
origen String? // meshcentral, librenms, headwind, sistema
origenId String? @map("origen_id")
reconocidaPor String? @map("reconocida_por")
reconocidaEn DateTime? @map("reconocida_en")
resueltaPor String? @map("resuelta_por")
resueltaEn DateTime? @map("resuelta_en")
notificada Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
dispositivo Dispositivo? @relation(fields: [dispositivoId], references: [id])
regla AlertaRegla? @relation(fields: [reglaId], references: [id])
@@index([clienteId, estado])
@@index([severidad])
@@index([createdAt])
@@map("alertas")
}
model AlertaRegla {
id String @id @default(cuid())
clienteId String? @map("cliente_id") // null = global
nombre String
descripcion String?
activa Boolean @default(true)
tipoDispositivo TipoDispositivo? @map("tipo_dispositivo")
metrica String // cpu, ram, disco, temperatura, etc
operador String // >, <, >=, <=, ==
umbral Float
duracionMinutos Int @default(5) @map("duracion_minutos")
severidad SeveridadAlerta
notificarEmail Boolean @default(true) @map("notificar_email")
notificarSms Boolean @default(false) @map("notificar_sms")
notificarWebhook Boolean @default(false) @map("notificar_webhook")
webhookUrl String? @map("webhook_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cliente Cliente? @relation(fields: [clienteId], references: [id], onDelete: Cascade)
alertas Alerta[]
@@map("alerta_reglas")
}
// ==================== USUARIOS ====================
enum RolUsuario {
SUPER_ADMIN
ADMIN
TECNICO
CLIENTE
VIEWER
}
model Usuario {
id String @id @default(cuid())
clienteId String? @map("cliente_id") // null = acceso global
email String @unique
nombre String
passwordHash String? @map("password_hash")
meshcentralUser String? @unique @map("meshcentral_user")
rol RolUsuario @default(VIEWER)
activo Boolean @default(true)
avatar String?
telefono String?
notificarEmail Boolean @default(true) @map("notificar_email")
notificarSms Boolean @default(false) @map("notificar_sms")
lastLogin DateTime? @map("last_login")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cliente Cliente? @relation(fields: [clienteId], references: [id], onDelete: SetNull)
permisos UsuarioPermiso[]
sesiones SesionRemota[]
auditLogs AuditLog[]
@@map("usuarios")
}
model UsuarioPermiso {
id String @id @default(cuid())
usuarioId String @map("usuario_id")
recurso String // dispositivos, alertas, reportes, configuracion, etc
accion String // read, write, delete, execute
permitido Boolean @default(true)
usuario Usuario @relation(fields: [usuarioId], references: [id], onDelete: Cascade)
@@unique([usuarioId, recurso, accion])
@@map("usuario_permisos")
}
// ==================== SESIONES Y AUDITORIA ====================
model SesionRemota {
id String @id @default(cuid())
usuarioId String @map("usuario_id")
dispositivoId String @map("dispositivo_id")
tipo String // desktop, terminal, files
meshSessionId String? @map("mesh_session_id")
iniciadaEn DateTime @default(now()) @map("iniciada_en")
finalizadaEn DateTime? @map("finalizada_en")
duracion Int? // segundos
ip String?
usuario Usuario @relation(fields: [usuarioId], references: [id])
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id])
@@map("sesiones_remotas")
}
model AuditLog {
id String @id @default(cuid())
usuarioId String? @map("usuario_id")
dispositivoId String? @map("dispositivo_id")
accion String
recurso String
detalles Json?
ip String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
usuario Usuario? @relation(fields: [usuarioId], references: [id])
dispositivo Dispositivo? @relation(fields: [dispositivoId], references: [id])
@@index([usuarioId])
@@index([createdAt])
@@map("audit_log")
}
// ==================== CONFIGURACION ====================
model Configuracion {
id String @id @default(cuid())
clave String @unique
valor Json
tipo String // string, number, boolean, json
categoria String // general, integracion, notificacion, etc
descripcion String?
updatedAt DateTime @updatedAt @map("updated_at")
@@map("configuracion")
}

72
scripts/backup-db.sh Normal file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
# MSP Monitor Dashboard - Database Backup Script
# Crea un backup de la base de datos PostgreSQL
set -e
# Configuracion
BACKUP_DIR="${BACKUP_DIR:-/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="msp_monitor_${DATE}.sql.gz"
# Cargar variables de entorno
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
fi
# Valores por defecto
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_USER="${POSTGRES_USER:-mspmonitor}"
DB_NAME="${POSTGRES_DB:-msp_monitor}"
DB_PASSWORD="${POSTGRES_PASSWORD:-changeme}"
echo "======================================"
echo " MSP Monitor - Database Backup"
echo "======================================"
echo "Fecha: $(date)"
echo "Base de datos: $DB_NAME"
echo ""
# Crear directorio de backups si no existe
mkdir -p "$BACKUP_DIR"
# Crear backup
echo "Creando backup..."
export PGPASSWORD="$DB_PASSWORD"
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
--no-owner --no-privileges --format=custom | gzip > "$BACKUP_DIR/$BACKUP_FILE"
unset PGPASSWORD
# Verificar que el backup se creo correctamente
if [ -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
echo "[✓] Backup creado: $BACKUP_DIR/$BACKUP_FILE ($SIZE)"
else
echo "[✗] Error creando backup"
exit 1
fi
# Eliminar backups antiguos
echo ""
echo "Limpiando backups antiguos (mas de $RETENTION_DAYS dias)..."
find "$BACKUP_DIR" -name "msp_monitor_*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete
REMAINING=$(ls -1 "$BACKUP_DIR"/msp_monitor_*.sql.gz 2>/dev/null | wc -l)
echo "[✓] Backups restantes: $REMAINING"
# Opcional: Subir a S3 si esta configurado
if [ -n "$S3_BUCKET" ] && command -v aws &> /dev/null; then
echo ""
echo "Subiendo backup a S3..."
aws s3 cp "$BACKUP_DIR/$BACKUP_FILE" "s3://$S3_BUCKET/backups/$BACKUP_FILE"
echo "[✓] Backup subido a s3://$S3_BUCKET/backups/$BACKUP_FILE"
fi
echo ""
echo "======================================"
echo " Backup completado"
echo "======================================"

103
scripts/restore-db.sh Normal file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
# MSP Monitor Dashboard - Database Restore Script
# Restaura un backup de la base de datos PostgreSQL
set -e
# Verificar argumentos
if [ -z "$1" ]; then
echo "Uso: $0 <archivo_backup>"
echo ""
echo "Ejemplo: $0 /backups/msp_monitor_20240115_120000.sql.gz"
echo ""
echo "Backups disponibles:"
ls -la ${BACKUP_DIR:-/backups}/msp_monitor_*.sql.gz 2>/dev/null || echo "No se encontraron backups"
exit 1
fi
BACKUP_FILE="$1"
# Verificar que el archivo existe
if [ ! -f "$BACKUP_FILE" ]; then
echo "[✗] Archivo no encontrado: $BACKUP_FILE"
exit 1
fi
# Cargar variables de entorno
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
fi
# Valores por defecto
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_USER="${POSTGRES_USER:-mspmonitor}"
DB_NAME="${POSTGRES_DB:-msp_monitor}"
DB_PASSWORD="${POSTGRES_PASSWORD:-changeme}"
echo "======================================"
echo " MSP Monitor - Database Restore"
echo "======================================"
echo "Fecha: $(date)"
echo "Archivo: $BACKUP_FILE"
echo "Base de datos: $DB_NAME"
echo ""
# Confirmar restauracion
echo "ADVERTENCIA: Esta operacion eliminara todos los datos actuales."
read -p "¿Desea continuar? (si/no): " CONFIRM
if [ "$CONFIRM" != "si" ]; then
echo "Operacion cancelada"
exit 0
fi
export PGPASSWORD="$DB_PASSWORD"
# Crear backup de seguridad antes de restaurar
echo ""
echo "Creando backup de seguridad antes de restaurar..."
SAFETY_BACKUP="/tmp/msp_monitor_pre_restore_$(date +%Y%m%d_%H%M%S).sql.gz"
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
--no-owner --no-privileges --format=custom | gzip > "$SAFETY_BACKUP"
echo "[✓] Backup de seguridad: $SAFETY_BACKUP"
# Terminar conexiones activas
echo ""
echo "Terminando conexiones activas..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c \
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" \
> /dev/null 2>&1 || true
# Eliminar y recrear base de datos
echo ""
echo "Recreando base de datos..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME;"
echo "[✓] Base de datos recreada"
# Restaurar backup
echo ""
echo "Restaurando backup..."
if [[ "$BACKUP_FILE" == *.gz ]]; then
gunzip -c "$BACKUP_FILE" | pg_restore -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges
else
pg_restore -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges "$BACKUP_FILE"
fi
echo "[✓] Backup restaurado"
unset PGPASSWORD
# Aplicar migraciones pendientes
echo ""
echo "Verificando migraciones..."
npx prisma db push --skip-generate 2>/dev/null || echo "[!] No se pudieron aplicar migraciones automaticamente"
echo ""
echo "======================================"
echo " Restauracion completada"
echo "======================================"
echo ""
echo "El backup de seguridad esta en: $SAFETY_BACKUP"
echo "Elimine manualmente cuando ya no lo necesite."

169
scripts/setup.sh Normal file
View File

@@ -0,0 +1,169 @@
#!/bin/bash
# MSP Monitor Dashboard - Setup Script
# Este script configura el entorno de desarrollo/produccion
set -e
echo "======================================"
echo " MSP Monitor Dashboard - Setup"
echo "======================================"
# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Funcion para imprimir mensajes
print_status() {
echo -e "${GREEN}[✓]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[!]${NC} $1"
}
print_error() {
echo -e "${RED}[✗]${NC} $1"
}
# Verificar si se ejecuta como root
if [ "$EUID" -ne 0 ]; then
print_warning "Este script debe ejecutarse como root para algunas operaciones"
fi
# Verificar dependencias
echo ""
echo "Verificando dependencias..."
check_command() {
if command -v $1 &> /dev/null; then
print_status "$1 instalado"
return 0
else
print_error "$1 no encontrado"
return 1
fi
}
MISSING_DEPS=0
check_command docker || MISSING_DEPS=1
check_command docker-compose || check_command "docker compose" || MISSING_DEPS=1
check_command node || MISSING_DEPS=1
check_command npm || MISSING_DEPS=1
if [ $MISSING_DEPS -eq 1 ]; then
print_error "Faltan dependencias. Instale las herramientas faltantes antes de continuar."
exit 1
fi
# Crear archivo .env si no existe
if [ ! -f .env ]; then
echo ""
echo "Creando archivo .env..."
cp .env.example .env
# Generar JWT_SECRET aleatorio
JWT_SECRET=$(openssl rand -base64 32)
sed -i "s|JWT_SECRET=.*|JWT_SECRET=\"$JWT_SECRET\"|" .env
print_status "Archivo .env creado"
print_warning "Edite el archivo .env con sus configuraciones antes de continuar"
else
print_status "Archivo .env ya existe"
fi
# Instalar dependencias de Node.js
echo ""
echo "Instalando dependencias de Node.js..."
npm install
print_status "Dependencias instaladas"
# Generar cliente de Prisma
echo ""
echo "Generando cliente de Prisma..."
npx prisma generate
print_status "Cliente de Prisma generado"
# Preguntar si iniciar en modo desarrollo o produccion
echo ""
echo "Seleccione el modo de ejecucion:"
echo "1) Desarrollo (npm run dev)"
echo "2) Produccion (Docker)"
echo "3) Solo configurar, no iniciar"
read -p "Opcion [1-3]: " MODE
case $MODE in
1)
echo ""
echo "Iniciando servicios de desarrollo..."
# Iniciar PostgreSQL y Redis con Docker
docker-compose -f docker/docker-compose.yml up -d postgres redis
# Esperar a que los servicios esten listos
echo "Esperando a que los servicios esten listos..."
sleep 5
# Aplicar migraciones
echo ""
echo "Aplicando migraciones de base de datos..."
npx prisma db push
print_status "Migraciones aplicadas"
# Iniciar aplicacion
echo ""
print_status "Iniciando servidor de desarrollo..."
npm run dev
;;
2)
echo ""
echo "Construyendo e iniciando contenedores de produccion..."
# Construir imagenes
docker-compose -f docker/docker-compose.yml build
# Iniciar servicios
docker-compose -f docker/docker-compose.yml up -d
# Esperar a que los servicios esten listos
echo "Esperando a que los servicios esten listos..."
sleep 10
# Aplicar migraciones
echo ""
echo "Aplicando migraciones de base de datos..."
docker-compose -f docker/docker-compose.yml exec dashboard npx prisma db push
print_status "Migraciones aplicadas"
print_status "Servicios iniciados"
echo ""
echo "Dashboard disponible en: http://localhost:3000"
echo ""
echo "Comandos utiles:"
echo " docker-compose -f docker/docker-compose.yml logs -f # Ver logs"
echo " docker-compose -f docker/docker-compose.yml down # Detener servicios"
echo " docker-compose -f docker/docker-compose.yml restart # Reiniciar servicios"
;;
3)
print_status "Configuracion completada"
echo ""
echo "Proximos pasos:"
echo "1. Edite el archivo .env con sus configuraciones"
echo "2. Ejecute 'npm run dev' para desarrollo"
echo "3. O ejecute 'docker-compose -f docker/docker-compose.yml up -d' para produccion"
;;
*)
print_error "Opcion no valida"
exit 1
;;
esac
echo ""
echo "======================================"
echo " Setup completado"
echo "======================================"

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
}

81
tailwind.config.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Tema cyan/navy oscuro
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#06b6d4',
600: '#0891b2',
700: '#0e7490',
800: '#155e75',
900: '#164e63',
950: '#083344',
},
navy: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
dark: {
100: '#1e293b',
200: '#1a2234',
300: '#151c2c',
400: '#111827',
500: '#0d1321',
600: '#0a0f1a',
700: '#070b14',
800: '#05080e',
900: '#030509',
},
// Estados
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
boxShadow: {
'glow': '0 0 20px rgba(6, 182, 212, 0.3)',
'glow-lg': '0 0 40px rgba(6, 182, 212, 0.4)',
'inner-glow': 'inset 0 0 20px rgba(6, 182, 212, 0.1)',
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px rgba(6, 182, 212, 0.2)' },
'100%': { boxShadow: '0 0 20px rgba(6, 182, 212, 0.6)' },
},
},
},
},
plugins: [],
}
export default config

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}