commit f4491757d9ab62c1013b37060a6691216357c6fa Author: MSP Monitor Date: Wed Jan 21 19:29:20 2026 +0000 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3760c2a --- /dev/null +++ b/.env.example @@ -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 " + +# 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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e75685f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d9162c --- /dev/null +++ b/README.md @@ -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) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..cf992f8 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..b71a38a --- /dev/null +++ b/docker/docker-compose.yml @@ -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 diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf new file mode 100644 index 0000000..f22525b --- /dev/null +++ b/docker/nginx/conf.d/default.conf @@ -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; + } +} diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..571ca5b --- /dev/null +++ b/docker/nginx/nginx.conf @@ -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; +} diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..65cc636 --- /dev/null +++ b/docs/api/README.md @@ -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" +} +``` diff --git a/docs/arquitectura/README.md b/docs/arquitectura/README.md new file mode 100644 index 0000000..dd67162 --- /dev/null +++ b/docs/arquitectura/README.md @@ -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 +``` diff --git a/docs/guias/configuracion.md b/docs/guias/configuracion.md new file mode 100644 index 0000000..644f032 --- /dev/null +++ b/docs/guias/configuracion.md @@ -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 " + +# 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 " +``` + +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', + // ... +} +``` diff --git a/docs/guias/instalacion.md b/docs/guias/instalacion.md new file mode 100644 index 0000000..6844426 --- /dev/null +++ b/docs/guias/instalacion.md @@ -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= +JWT_SECRET= + +# 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 + +# 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) diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..5e73001 --- /dev/null +++ b/next.config.js @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..c990e5c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..3559b68 --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100644 index 0000000..b43b0fd --- /dev/null +++ b/scripts/backup-db.sh @@ -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 "======================================" diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh new file mode 100644 index 0000000..01f6b4d --- /dev/null +++ b/scripts/restore-db.sh @@ -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 " + 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." diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..e4b9c9c --- /dev/null +++ b/scripts/setup.sh @@ -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 "======================================" diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..b596cb9 --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -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 ( +
+ +
+
+
+ {children} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..1887796 --- /dev/null +++ b/src/app/(dashboard)/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+

Dashboard

+

Vision general del sistema

+
+
+ +
+
+ + {/* KPI Cards */} + + + {/* Main content */} +
+ {/* Devices */} +
+
+

Dispositivos

+
+ +
+ + +
+
+
+ + +
+ + {/* Alerts */} +
+ +
+
+
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..75ec114 --- /dev/null +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..af7b17f --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + {children} + + + ) +} diff --git a/src/components/dashboard/AlertsFeed.tsx b/src/components/dashboard/AlertsFeed.tsx new file mode 100644 index 0000000..4dea834 --- /dev/null +++ b/src/components/dashboard/AlertsFeed.tsx @@ -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 ( +
+ +

No hay alertas activas

+
+ ) + } + + return ( +
+
+

Alertas Recientes

+ + Ver todas + +
+ +
+ {displayAlerts.map((alert) => ( + + ))} +
+
+ ) +} + +function AlertItem({ + alert, + onAcknowledge, + onResolve, +}: { + alert: Alert + onAcknowledge?: (alertId: string) => void + onResolve?: (alertId: string) => void +}) { + const severityConfig = { + CRITICAL: { + icon: , + color: 'text-danger', + bgColor: 'bg-danger/20', + borderColor: 'border-l-danger', + }, + WARNING: { + icon: , + color: 'text-warning', + bgColor: 'bg-warning/20', + borderColor: 'border-l-warning', + }, + INFO: { + icon: , + color: 'text-info', + bgColor: 'bg-info/20', + borderColor: 'border-l-info', + }, + } + + const config = severityConfig[alert.severidad] + + return ( +
+
+
+ {config.icon} +
+ +
+
+
+

{alert.titulo}

+

{alert.mensaje}

+
+ + {alert.estado} + +
+ +
+
+ + {formatRelativeTime(alert.createdAt)} +
+ {alert.dispositivo && ( + + {alert.dispositivo.nombre} + + )} + + {alert.cliente.nombre} + +
+ + {alert.estado === 'ACTIVA' && ( +
+ + +
+ )} +
+
+
+ ) +} diff --git a/src/components/dashboard/DeviceGrid.tsx b/src/components/dashboard/DeviceGrid.tsx new file mode 100644 index 0000000..c983377 --- /dev/null +++ b/src/components/dashboard/DeviceGrid.tsx @@ -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 = { + PC: , + LAPTOP: , + SERVIDOR: , + CELULAR: , + TABLET: , + ROUTER: , + SWITCH: , + FIREWALL: , + AP: , + IMPRESORA: , + OTRO: , +} + +export default function DeviceGrid({ devices, viewMode = 'grid', onAction }: DeviceGridProps) { + if (viewMode === 'list') { + return + } + + return ( +
+ {devices.map((device) => ( + + ))} +
+ ) +} + +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 ( +
+ {/* Status indicator */} +
+ +
+ + + {showMenu && ( + <> +
setShowMenu(false)} /> +
+ {['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && ( + <> + + + +
+ + )} + +
+ + )} +
+
+ + {/* Icon and name */} + +
+
+ + {deviceIcons[device.tipo] || deviceIcons.OTRO} + +
+
+

{device.nombre}

+

{device.tipo}

+
+
+ + + {/* Details */} +
+ {device.ip && ( +
+ IP + {device.ip} +
+ )} + {device.sistemaOperativo && ( +
+ OS + {device.sistemaOperativo} +
+ )} + {device.lastSeen && ( +
+ Visto + {formatRelativeTime(device.lastSeen)} +
+ )} +
+ + {/* Metrics bar */} + {device.estado === 'ONLINE' && (device.cpuUsage !== null || device.ramUsage !== null) && ( +
+ {device.cpuUsage !== null && ( +
+
+ CPU + 80 ? 'text-danger' : 'text-gray-400')}> + {Math.round(device.cpuUsage)}% + +
+
+
80 ? 'bg-danger' : device.cpuUsage > 60 ? 'bg-warning' : 'bg-success' + )} + style={{ width: `${device.cpuUsage}%` }} + /> +
+
+ )} + {device.ramUsage !== null && ( +
+
+ RAM + 80 ? 'text-danger' : 'text-gray-400')}> + {Math.round(device.ramUsage)}% + +
+
+
80 ? 'bg-danger' : device.ramUsage > 60 ? 'bg-warning' : 'bg-success' + )} + style={{ width: `${device.ramUsage}%` }} + /> +
+
+ )} +
+ )} +
+ ) +} + +function DeviceList({ + devices, + onAction, +}: { + devices: Device[] + onAction?: (deviceId: string, action: string) => void +}) { + return ( +
+ + + + + + + + + + + + + + + {devices.map((device) => ( + + + + + + + + + + + ))} + +
DispositivoTipoIPEstadoCPURAMUltimo contacto
+
+ + {deviceIcons[device.tipo] || deviceIcons.OTRO} + +
+
{device.nombre}
+ {device.cliente && ( +
{device.cliente.nombre}
+ )} +
+
+
{device.tipo}{device.ip || '-'} + + {device.estado} + + + {device.cpuUsage !== null ? ( + 80 ? 'text-danger' : 'text-gray-400')}> + {Math.round(device.cpuUsage)}% + + ) : ( + '-' + )} + + {device.ramUsage !== null ? ( + 80 ? 'text-danger' : 'text-gray-400')}> + {Math.round(device.ramUsage)}% + + ) : ( + '-' + )} + + {device.lastSeen ? formatRelativeTime(device.lastSeen) : '-'} + + + Ver + +
+
+ ) +} diff --git a/src/components/dashboard/KPICards.tsx b/src/components/dashboard/KPICards.tsx new file mode 100644 index 0000000..918b78f --- /dev/null +++ b/src/components/dashboard/KPICards.tsx @@ -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: , + color: 'text-primary-400', + bgColor: 'bg-primary-900/30', + }, + { + title: 'En Linea', + value: stats.dispositivosOnline, + icon: , + 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: , + color: 'text-gray-400', + bgColor: 'bg-gray-500/20', + }, + { + title: 'Con Alertas', + value: stats.dispositivosAlerta, + icon: , + color: 'text-warning', + bgColor: 'bg-warning/20', + }, + { + title: 'Alertas Activas', + value: stats.alertasActivas, + icon: , + color: 'text-danger', + bgColor: 'bg-danger/20', + highlight: stats.alertasCriticas > 0, + subtitle: stats.alertasCriticas > 0 + ? `${stats.alertasCriticas} criticas` + : undefined, + }, + ] + + return ( +
+ {cards.map((card, index) => ( +
+
+
+

{card.title}

+

{card.value}

+ {card.subtitle && ( +

{card.subtitle}

+ )} + {card.percentage !== undefined && ( +

{card.percentage}% del total

+ )} +
+
+ {card.icon} +
+
+
+ ))} +
+ ) +} diff --git a/src/components/layout/ClientSelector.tsx b/src/components/layout/ClientSelector.tsx new file mode 100644 index 0000000..e1acbd8 --- /dev/null +++ b/src/components/layout/ClientSelector.tsx @@ -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 ( +
+ + + {open && ( + <> +
{ + setOpen(false) + setSearch('') + }} + /> +
+ {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Buscar cliente..." + className="input py-1.5 pl-8 text-sm" + autoFocus + /> +
+
+ + {/* Options */} +
+ {showAll && ( + + )} + + {filteredClients.length === 0 ? ( +
+ No se encontraron clientes +
+ ) : ( + filteredClients.map((client) => ( + + )) + )} +
+
+ + )} +
+ ) +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..f8df12f --- /dev/null +++ b/src/components/layout/Header.tsx @@ -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 ( +
+ {/* Search */} +
+
+ + +
+ + {/* Client Selector */} + +
+ + {/* Right section */} +
+ {/* Notifications */} +
+ + + {showNotifications && ( + <> +
setShowNotifications(false)} + /> +
+
+

Notificaciones

+
+
+ + + +
+ +
+ + )} +
+ + {/* User menu */} +
+ + + {showUserMenu && ( + <> +
setShowUserMenu(false)} + /> +
+
+
{user?.nombre}
+
{user?.email}
+
+ + + Mi perfil + + + + Configuracion + +
+ +
+ + )} +
+
+
+ ) +} + +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 ( +
+
{title}
+
{message}
+
{time}
+
+ ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..0c88001 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -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: , + }, + { + label: 'Equipos', + href: '/equipos', + icon: , + }, + { + label: 'Celulares', + href: '/celulares', + icon: , + }, + { + label: 'Red', + href: '/red', + icon: , + }, + { + label: 'Alertas', + href: '/alertas', + icon: , + }, + { + label: 'Reportes', + href: '/reportes', + icon: , + }, +] + +const adminItems: NavItem[] = [ + { + label: 'Clientes', + href: '/clientes', + icon: , + }, + { + label: 'Usuarios', + href: '/usuarios', + icon: , + }, + { + label: 'Configuracion', + href: '/configuracion', + icon: , + }, +] + +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 ( + + ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..2568f62 --- /dev/null +++ b/src/lib/auth.ts @@ -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 { + 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 { + try { + const { payload } = await jwtVerify(token, JWT_SECRET) + return payload as unknown as SessionUser + } catch { + return null + } +} + +export async function getSession(): Promise { + const cookieStore = cookies() + const token = cookieStore.get(COOKIE_NAME)?.value + + if (!token) return null + return verifySession(token) +} + +export async function setSessionCookie(token: string): Promise { + 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 { + const cookieStore = cookies() + cookieStore.delete(COOKIE_NAME) +} + +export async function validateMeshCentralUser(username: string, token: string): Promise { + // 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 + } +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..13ce178 --- /dev/null +++ b/src/lib/prisma.ts @@ -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 diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..aa67fc0 --- /dev/null +++ b/src/lib/redis.ts @@ -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(key: string): Promise { + const data = await redis.get(key) + if (!data) return null + return JSON.parse(data) as T +} + +export async function setCache(key: string, value: T, ttlSeconds: number = 300): Promise { + await redis.setex(key, ttlSeconds, JSON.stringify(value)) +} + +export async function deleteCache(key: string): Promise { + await redis.del(key) +} + +export async function invalidatePattern(pattern: string): Promise { + const keys = await redis.keys(pattern) + if (keys.length > 0) { + await redis.del(...keys) + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..3b94cd6 --- /dev/null +++ b/src/lib/utils.ts @@ -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) => ReturnType>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => func(...args), wait) + } +} + +export function generateId(): string { + return Math.random().toString(36).substring(2, 15) +} diff --git a/src/server/jobs/alert-processor.job.ts b/src/server/jobs/alert-processor.job.ts new file mode 100644 index 0000000..10ad2f3 --- /dev/null +++ b/src/server/jobs/alert-processor.job.ts @@ -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[] = [] + + 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 = {} + + 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'}` +} diff --git a/src/server/jobs/maintenance.job.ts b/src/server/jobs/maintenance.job.ts new file mode 100644 index 0000000..ad13739 --- /dev/null +++ b/src/server/jobs/maintenance.job.ts @@ -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 { + 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 { + 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 { + console.log('Ejecutando health check...') + + const results: Record = {} + + // 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)) +} diff --git a/src/server/jobs/notification.job.ts b/src/server/jobs/notification.job.ts new file mode 100644 index 0000000..231e44c --- /dev/null +++ b/src/server/jobs/notification.job.ts @@ -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 { + const transporter = getEmailTransporter() + + try { + await transporter.sendMail({ + from: process.env.SMTP_FROM || 'MSP Monitor ', + 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 { + 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 { + 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 ` + + + + + + + + +
+

MSP Monitor

+
+
+
${escapeHtml(titulo)}
+
${escapeHtml(mensaje)}
+
+ + + +`.trim() +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, '
') +} diff --git a/src/server/jobs/queue.ts b/src/server/jobs/queue.ts new file mode 100644 index 0000000..972ccb1 --- /dev/null +++ b/src/server/jobs/queue.ts @@ -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 } diff --git a/src/server/jobs/sync-headwind.job.ts b/src/server/jobs/sync-headwind.job.ts new file mode 100644 index 0000000..defa06e --- /dev/null +++ b/src/server/jobs/sync-headwind.job.ts @@ -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, + }) +} diff --git a/src/server/jobs/sync-librenms.job.ts b/src/server/jobs/sync-librenms.job.ts new file mode 100644 index 0000000..90818d4 --- /dev/null +++ b/src/server/jobs/sync-librenms.job.ts @@ -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, + }) +} diff --git a/src/server/jobs/sync-meshcentral.job.ts b/src/server/jobs/sync-meshcentral.job.ts new file mode 100644 index 0000000..648e372 --- /dev/null +++ b/src/server/jobs/sync-meshcentral.job.ts @@ -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() + 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, + }) +} diff --git a/src/server/jobs/worker.ts b/src/server/jobs/worker.ts new file mode 100644 index 0000000..f95c102 --- /dev/null +++ b/src/server/jobs/worker.ts @@ -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( + '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( + '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( + '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( + '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) diff --git a/src/server/services/headwind/client.ts b/src/server/services/headwind/client.ts new file mode 100644 index 0000000..62ae1c7 --- /dev/null +++ b/src/server/services/headwind/client.ts @@ -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) { + this.config = { + url: config?.url || process.env.HEADWIND_URL || '', + token: config?.token || process.env.HEADWIND_TOKEN || '', + } + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + 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 { + const data = await this.request<{ data: HeadwindDevice[] }>('/public/v1/devices') + return data.data || [] + } + + // Obtener dispositivo por ID + async getDevice(deviceId: number): Promise { + try { + const data = await this.request(`/public/v1/devices/${deviceId}`) + return data + } catch { + return null + } + } + + // Obtener dispositivos por grupo + async getDevicesByGroup(groupId: number): Promise { + const data = await this.request<{ data: HeadwindDevice[] }>(`/public/v1/groups/${groupId}/devices`) + return data.data || [] + } + + // Solicitar actualizacion de ubicacion + async requestLocation(deviceId: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await this.request(`/public/v1/devices/${deviceId}/command/wipe`, { + method: 'POST', + }) + return true + } catch { + return false + } + } + + // Reiniciar dispositivo + async rebootDevice(deviceId: number): Promise { + 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> { + 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 { + 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 { + 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 { + const data = await this.request<{ data: HeadwindConfiguration[] }>('/public/v1/configurations') + return data.data || [] + } + + // Asignar configuracion a dispositivo + async setDeviceConfiguration(deviceId: number, configurationId: number): Promise { + try { + await this.request(`/public/v1/devices/${deviceId}`, { + method: 'PUT', + body: JSON.stringify({ configurationId }), + }) + return true + } catch { + return false + } + } + + // Obtener grupos + async getGroups(): Promise> { + 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 { + 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 { + 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 { + 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> { + 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 { + try { + await this.request('/public/v1/info') + return true + } catch { + return false + } + } +} + +export default HeadwindClient diff --git a/src/server/services/librenms/client.ts b/src/server/services/librenms/client.ts new file mode 100644 index 0000000..9d66063 --- /dev/null +++ b/src/server/services/librenms/client.ts @@ -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) { + this.config = { + url: config?.url || process.env.LIBRENMS_URL || '', + token: config?.token || process.env.LIBRENMS_TOKEN || '', + } + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + 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 { + const data = await this.request<{ devices: LibreNMSDevice[] }>('/devices') + return data.devices || [] + } + + // Obtener dispositivo por ID + async getDevice(deviceId: number): Promise { + 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 { + const data = await this.request<{ devices: LibreNMSDevice[] }>(`/devicegroups/${groupId}`) + return data.devices || [] + } + + // Obtener puertos de un dispositivo + async getDevicePorts(deviceId: number): Promise { + 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> { + 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 { + const data = await this.request<{ alerts: LibreNMSAlert[] }>('/alerts?state=1') + return data.alerts || [] + } + + // Obtener alertas de un dispositivo + async getDeviceAlerts(deviceId: number): Promise { + const data = await this.request<{ alerts: LibreNMSAlert[] }>(`/devices/${deviceId}/alerts`) + return data.alerts || [] + } + + // Obtener historial de alertas + async getAlertLog(limit = 100): Promise { + const data = await this.request<{ alerts: LibreNMSAlert[] }>(`/alerts?limit=${limit}`) + return data.alerts || [] + } + + // Obtener grupos de dispositivos + async getDeviceGroups(): Promise> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await this.request('/system') + return true + } catch { + return false + } + } +} + +export default LibreNMSClient diff --git a/src/server/services/meshcentral/client.ts b/src/server/services/meshcentral/client.ts new file mode 100644 index 0000000..b1b9588 --- /dev/null +++ b/src/server/services/meshcentral/client.ts @@ -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) { + 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 { + 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(endpoint: string, options: RequestInit = {}): Promise { + 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 { + const data = await this.request<{ nodes: Record }>('/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 { + const devices = await this.getDevices() + return devices.find(d => d._id === nodeId) || null + } + + // Obtener grupos (meshes) + async getMeshes(): Promise> { + 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 { + const data = await this.request<{ nodes: Record }>(`/api/meshes/${meshId}`) + return data.nodes[meshId] || [] + } + + // Ejecutar comando en dispositivo + async runCommand(nodeId: string, command: string, type: 'powershell' | 'cmd' | 'bash' = 'powershell'): Promise { + 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 { + 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 { + 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> { + const data = await this.request<{ info: Record }>(`/api/nodes/${nodeId}/info`) + return data.info || {} + } + + // Obtener procesos en ejecucion + async getProcesses(nodeId: string): Promise> { + 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> { + 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 { + 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 diff --git a/src/server/trpc/routers/alertas.router.ts b/src/server/trpc/routers/alertas.router.ts new file mode 100644 index 0000000..2832658 --- /dev/null +++ b/src/server/trpc/routers/alertas.router.ts @@ -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 } }) + }), + }), +}) diff --git a/src/server/trpc/routers/auth.router.ts b/src/server/trpc/routers/auth.router.ts new file mode 100644 index 0000000..286811b --- /dev/null +++ b/src/server/trpc/routers/auth.router.ts @@ -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 } + }), +}) diff --git a/src/server/trpc/routers/celulares.router.ts b/src/server/trpc/routers/celulares.router.ts new file mode 100644 index 0000000..6ca1ccd --- /dev/null +++ b/src/server/trpc/routers/celulares.router.ts @@ -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 } + }), +}) diff --git a/src/server/trpc/routers/clientes.router.ts b/src/server/trpc/routers/clientes.router.ts new file mode 100644 index 0000000..6d609d4 --- /dev/null +++ b/src/server/trpc/routers/clientes.router.ts @@ -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 } }) + }), + }), +}) diff --git a/src/server/trpc/routers/configuracion.router.ts b/src/server/trpc/routers/configuracion.router.ts new file mode 100644 index 0000000..bf5ae05 --- /dev/null +++ b/src/server/trpc/routers/configuracion.router.ts @@ -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}`, + } + }), + }), +}) diff --git a/src/server/trpc/routers/equipos.router.ts b/src/server/trpc/routers/equipos.router.ts new file mode 100644 index 0000000..cc0a138 --- /dev/null +++ b/src/server/trpc/routers/equipos.router.ts @@ -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 } + }), +}) diff --git a/src/server/trpc/routers/index.ts b/src/server/trpc/routers/index.ts new file mode 100644 index 0000000..634a452 --- /dev/null +++ b/src/server/trpc/routers/index.ts @@ -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 diff --git a/src/server/trpc/routers/red.router.ts b/src/server/trpc/routers/red.router.ts new file mode 100644 index 0000000..884edf2 --- /dev/null +++ b/src/server/trpc/routers/red.router.ts @@ -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 } + }), +}) diff --git a/src/server/trpc/routers/reportes.router.ts b/src/server/trpc/routers/reportes.router.ts new file mode 100644 index 0000000..98023bd --- /dev/null +++ b/src/server/trpc/routers/reportes.router.ts @@ -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', + } + }), +}) diff --git a/src/server/trpc/routers/usuarios.router.ts b/src/server/trpc/routers/usuarios.router.ts new file mode 100644 index 0000000..fd9ac1f --- /dev/null +++ b/src/server/trpc/routers/usuarios.router.ts @@ -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', + }, + }) + }), +}) diff --git a/src/server/trpc/trpc.ts b/src/server/trpc/trpc.ts new file mode 100644 index 0000000..eeb4bb1 --- /dev/null +++ b/src/server/trpc/trpc.ts @@ -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 { + const user = await getSession() + return { + user, + prisma, + } +} + +const t = initTRPC.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) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..06d9dfa --- /dev/null +++ b/src/types/index.ts @@ -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 +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..a40cc1e --- /dev/null +++ b/tailwind.config.ts @@ -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 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7b28589 --- /dev/null +++ b/tsconfig.json @@ -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"] +}