Initial commit: MSP Monitor Dashboard
- Next.js 14 frontend with dark cyan/navy theme - tRPC API with Prisma ORM - MeshCentral, LibreNMS, Headwind MDM integrations - Multi-tenant architecture - Alert system with email/SMS/webhook notifications - Docker Compose deployment - Complete documentation
This commit is contained in:
40
.env.example
Normal file
40
.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Base de datos PostgreSQL
|
||||
DATABASE_URL="postgresql://mspmonitor:password@localhost:5432/msp_monitor?schema=public"
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# JWT Secret para sesiones
|
||||
JWT_SECRET="your-super-secret-jwt-key-min-32-chars"
|
||||
|
||||
# MeshCentral
|
||||
MESHCENTRAL_URL="https://mesh.tudominio.com"
|
||||
MESHCENTRAL_USER="admin"
|
||||
MESHCENTRAL_PASS="password"
|
||||
MESHCENTRAL_DOMAIN="default"
|
||||
|
||||
# LibreNMS
|
||||
LIBRENMS_URL="https://librenms.tudominio.com"
|
||||
LIBRENMS_TOKEN="your-librenms-api-token"
|
||||
|
||||
# Headwind MDM
|
||||
HEADWIND_URL="https://mdm.tudominio.com"
|
||||
HEADWIND_TOKEN="your-headwind-api-token"
|
||||
|
||||
# SMTP para notificaciones
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="tu-email@gmail.com"
|
||||
SMTP_PASS="tu-app-password"
|
||||
SMTP_FROM="MSP Monitor <noreply@tudominio.com>"
|
||||
|
||||
# Twilio para SMS (opcional)
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_PHONE_NUMBER=""
|
||||
|
||||
# URL base de la aplicacion
|
||||
NEXT_PUBLIC_APP_URL="https://monitor.tudominio.com"
|
||||
|
||||
# Modo de desarrollo
|
||||
NODE_ENV="development"
|
||||
70
.gitignore
vendored
Normal file
70
.gitignore
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
|
||||
# Docker volumes
|
||||
docker/nginx/ssl/
|
||||
docker/data/
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
*.sql.gz
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Package manager locks (keep one)
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
.vercel/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
165
README.md
Normal file
165
README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# MSP Monitor Dashboard
|
||||
|
||||
Dashboard de monitoreo unificado para Proveedores de Servicios Administrados (MSP). Integra MeshCentral, LibreNMS y Headwind MDM en una sola interfaz moderna con tema oscuro cyan/navy.
|
||||
|
||||

|
||||
|
||||
## Caracteristicas
|
||||
|
||||
- **Monitoreo Unificado**: Visualiza todos tus dispositivos en una sola interfaz
|
||||
- **Multi-Tenant**: Gestion de multiples clientes con aislamiento de datos
|
||||
- **Control Remoto**: Acceso a escritorio, terminal y archivos via MeshCentral
|
||||
- **MDM Empresarial**: Gestion completa de dispositivos Android con Headwind MDM
|
||||
- **Monitoreo de Red**: SNMP, NetFlow y alertas via LibreNMS
|
||||
- **Sistema de Alertas**: Reglas personalizables con notificaciones por email, SMS y webhook
|
||||
- **Reportes**: Inventario, uptime, alertas y actividad de usuarios
|
||||
- **API Type-Safe**: Backend con tRPC para comunicacion tipada
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
| Componente | Tecnologia |
|
||||
|------------|------------|
|
||||
| Frontend | Next.js 14, React, Tailwind CSS |
|
||||
| Backend | Next.js API Routes, tRPC |
|
||||
| Base de Datos | PostgreSQL 16 + Prisma ORM |
|
||||
| Cache/Queue | Redis 7 + BullMQ |
|
||||
| Monitoreo PC | MeshCentral |
|
||||
| Monitoreo Red | LibreNMS |
|
||||
| MDM Movil | Headwind MDM |
|
||||
| Proxy/SSL | Nginx + Let's Encrypt |
|
||||
| Contenedores | Docker + Docker Compose |
|
||||
|
||||
## Inicio Rapido
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Node.js 20+
|
||||
- Docker y Docker Compose
|
||||
- Acceso a MeshCentral, LibreNMS y/o Headwind MDM
|
||||
|
||||
### Instalacion
|
||||
|
||||
```bash
|
||||
# Clonar repositorio
|
||||
git clone https://git.consultoria-as.com/msp/msp-monitor-dashboard.git
|
||||
cd msp-monitor-dashboard
|
||||
|
||||
# Ejecutar script de setup
|
||||
chmod +x scripts/setup.sh
|
||||
./scripts/setup.sh
|
||||
```
|
||||
|
||||
### Configuracion Rapida
|
||||
|
||||
1. Copia el archivo de configuracion:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edita `.env` con tus credenciales:
|
||||
```env
|
||||
# Base de datos
|
||||
DATABASE_URL="postgresql://user:pass@localhost:5432/msp_monitor"
|
||||
|
||||
# Integraciones
|
||||
MESHCENTRAL_URL="https://mesh.tudominio.com"
|
||||
MESHCENTRAL_USER="admin"
|
||||
MESHCENTRAL_PASS="password"
|
||||
|
||||
LIBRENMS_URL="https://librenms.tudominio.com"
|
||||
LIBRENMS_TOKEN="tu-token"
|
||||
|
||||
HEADWIND_URL="https://mdm.tudominio.com"
|
||||
HEADWIND_TOKEN="tu-token"
|
||||
```
|
||||
|
||||
3. Inicia los servicios:
|
||||
```bash
|
||||
# Desarrollo
|
||||
npm run dev
|
||||
|
||||
# Produccion
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
4. Accede al dashboard en `http://localhost:3000`
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
msp-monitor-dashboard/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── (dashboard)/ # Paginas del dashboard
|
||||
│ │ └── api/ # API endpoints
|
||||
│ ├── components/ # Componentes React
|
||||
│ │ ├── layout/ # Layout components
|
||||
│ │ └── dashboard/ # Dashboard components
|
||||
│ ├── server/ # Backend
|
||||
│ │ ├── trpc/ # tRPC routers
|
||||
│ │ ├── services/ # Clientes de integracion
|
||||
│ │ └── jobs/ # Workers de procesamiento
|
||||
│ ├── lib/ # Utilidades
|
||||
│ └── types/ # Tipos TypeScript
|
||||
├── prisma/ # Schema de base de datos
|
||||
├── docker/ # Configuracion Docker
|
||||
│ ├── nginx/ # Configuracion Nginx
|
||||
│ └── docker-compose.yml
|
||||
├── scripts/ # Scripts de utilidad
|
||||
└── docs/ # Documentacion
|
||||
```
|
||||
|
||||
## Documentacion
|
||||
|
||||
- [Arquitectura](docs/arquitectura/README.md)
|
||||
- [API Reference](docs/api/README.md)
|
||||
- [Guia de Instalacion](docs/guias/instalacion.md)
|
||||
- [Guia de Configuracion](docs/guias/configuracion.md)
|
||||
|
||||
## Comandos Utiles
|
||||
|
||||
```bash
|
||||
# Desarrollo
|
||||
npm run dev # Iniciar servidor de desarrollo
|
||||
npm run build # Compilar para produccion
|
||||
npm run lint # Verificar codigo
|
||||
|
||||
# Base de datos
|
||||
npm run db:generate # Generar cliente Prisma
|
||||
npm run db:push # Aplicar schema a BD
|
||||
npm run db:studio # Abrir Prisma Studio
|
||||
|
||||
# Workers
|
||||
npm run jobs:start # Iniciar workers de procesamiento
|
||||
|
||||
# Docker
|
||||
docker-compose -f docker/docker-compose.yml up -d # Iniciar servicios
|
||||
docker-compose -f docker/docker-compose.yml down # Detener servicios
|
||||
docker-compose -f docker/docker-compose.yml logs -f # Ver logs
|
||||
|
||||
# Backups
|
||||
./scripts/backup-db.sh # Crear backup
|
||||
./scripts/restore-db.sh /ruta/backup.sql.gz # Restaurar backup
|
||||
```
|
||||
|
||||
## Contribuir
|
||||
|
||||
1. Fork el repositorio
|
||||
2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`)
|
||||
3. Commit tus cambios (`git commit -am 'Agregar nueva funcionalidad'`)
|
||||
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
|
||||
5. Crea un Pull Request
|
||||
|
||||
## Licencia
|
||||
|
||||
Este proyecto es privado y propietario de Consultoria AS.
|
||||
|
||||
## Soporte
|
||||
|
||||
Para soporte tecnico, contacta a:
|
||||
- Email: soporte@consultoria-as.com
|
||||
- Web: https://consultoria-as.com
|
||||
|
||||
---
|
||||
|
||||
Desarrollado con ❤️ por [Consultoria AS](https://consultoria-as.com)
|
||||
49
docker/Dockerfile
Normal file
49
docker/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy necessary files from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
148
docker/docker-compose.yml
Normal file
148
docker/docker-compose.yml
Normal file
@@ -0,0 +1,148 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: msp-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mspmonitor}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-msp_monitor}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mspmonitor}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- msp-network
|
||||
|
||||
# Redis Cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: msp-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- msp-network
|
||||
|
||||
# MSP Monitor Dashboard (Next.js App)
|
||||
dashboard:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: msp-dashboard
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mspmonitor}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-msp_monitor}?schema=public
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- MESHCENTRAL_URL=${MESHCENTRAL_URL}
|
||||
- MESHCENTRAL_USER=${MESHCENTRAL_USER}
|
||||
- MESHCENTRAL_PASS=${MESHCENTRAL_PASS}
|
||||
- MESHCENTRAL_DOMAIN=${MESHCENTRAL_DOMAIN:-default}
|
||||
- LIBRENMS_URL=${LIBRENMS_URL}
|
||||
- LIBRENMS_TOKEN=${LIBRENMS_TOKEN}
|
||||
- HEADWIND_URL=${HEADWIND_URL}
|
||||
- HEADWIND_TOKEN=${HEADWIND_TOKEN}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_FROM=${SMTP_FROM}
|
||||
- NEXT_PUBLIC_APP_URL=${APP_URL:-http://localhost:3000}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- msp-network
|
||||
|
||||
# Background Worker (Jobs)
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: msp-worker
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/server/jobs/worker.js"]
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mspmonitor}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-msp_monitor}?schema=public
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- MESHCENTRAL_URL=${MESHCENTRAL_URL}
|
||||
- MESHCENTRAL_USER=${MESHCENTRAL_USER}
|
||||
- MESHCENTRAL_PASS=${MESHCENTRAL_PASS}
|
||||
- MESHCENTRAL_DOMAIN=${MESHCENTRAL_DOMAIN:-default}
|
||||
- LIBRENMS_URL=${LIBRENMS_URL}
|
||||
- LIBRENMS_TOKEN=${LIBRENMS_TOKEN}
|
||||
- HEADWIND_URL=${HEADWIND_URL}
|
||||
- HEADWIND_TOKEN=${HEADWIND_TOKEN}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_FROM=${SMTP_FROM}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- msp-network
|
||||
|
||||
# Nginx Reverse Proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: msp-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- certbot_data:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- dashboard
|
||||
networks:
|
||||
- msp-network
|
||||
|
||||
# Certbot for SSL
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: msp-certbot
|
||||
volumes:
|
||||
- ./nginx/ssl:/etc/letsencrypt
|
||||
- certbot_data:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
networks:
|
||||
- msp-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
certbot_data:
|
||||
|
||||
networks:
|
||||
msp-network:
|
||||
driver: bridge
|
||||
84
docker/nginx/conf.d/default.conf
Normal file
84
docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,84 @@
|
||||
# HTTP - Redirect to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Let's Encrypt challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS - Main site
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name monitor.tudominio.com;
|
||||
|
||||
# SSL certificates
|
||||
ssl_certificate /etc/nginx/ssl/live/monitor.tudominio.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/live/monitor.tudominio.com/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Modern SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# Max upload size
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Proxy to Next.js dashboard
|
||||
location / {
|
||||
proxy_pass http://dashboard;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# API rate limiting
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
limit_conn conn 10;
|
||||
|
||||
proxy_pass http://dashboard;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static files caching
|
||||
location /_next/static/ {
|
||||
proxy_pass http://dashboard;
|
||||
proxy_cache_valid 200 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
53
docker/nginx/nginx.conf
Normal file
53
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,53 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_conn_zone $binary_remote_addr zone=conn:10m;
|
||||
|
||||
# Upstream servers
|
||||
upstream dashboard {
|
||||
server dashboard:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Include site configurations
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
516
docs/api/README.md
Normal file
516
docs/api/README.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# API Reference
|
||||
|
||||
## Vision General
|
||||
|
||||
La API de MSP Monitor Dashboard utiliza [tRPC](https://trpc.io/) para comunicacion type-safe entre frontend y backend. Todos los endpoints estan disponibles bajo `/api/trpc`.
|
||||
|
||||
## Autenticacion
|
||||
|
||||
### Login con Email/Password
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/auth.login
|
||||
const result = await trpc.auth.login.mutate({
|
||||
email: "usuario@example.com",
|
||||
password: "password123"
|
||||
})
|
||||
// Returns: { success: true, user: { id, email, nombre, rol } }
|
||||
```
|
||||
|
||||
### Login con MeshCentral SSO
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/auth.loginMeshCentral
|
||||
const result = await trpc.auth.loginMeshCentral.mutate({
|
||||
username: "meshuser",
|
||||
token: "mesh-auth-token"
|
||||
})
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/auth.logout
|
||||
await trpc.auth.logout.mutate()
|
||||
```
|
||||
|
||||
### Usuario Actual
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/auth.me
|
||||
const user = await trpc.auth.me.query()
|
||||
// Returns: { id, email, nombre, rol, cliente, permisos }
|
||||
```
|
||||
|
||||
## Clientes (Tenants)
|
||||
|
||||
### Listar Clientes
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/clientes.list
|
||||
const { clientes, pagination } = await trpc.clientes.list.query({
|
||||
search: "term",
|
||||
activo: true,
|
||||
page: 1,
|
||||
limit: 20
|
||||
})
|
||||
```
|
||||
|
||||
### Obtener Cliente
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/clientes.byId
|
||||
const cliente = await trpc.clientes.byId.query({ id: "client-id" })
|
||||
```
|
||||
|
||||
### Crear Cliente
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/clientes.create
|
||||
const cliente = await trpc.clientes.create.mutate({
|
||||
nombre: "Empresa XYZ",
|
||||
codigo: "XYZ",
|
||||
email: "contacto@xyz.com",
|
||||
meshcentralGrupo: "mesh-group-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Estadisticas del Dashboard
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/clientes.dashboardStats
|
||||
const stats = await trpc.clientes.dashboardStats.query({
|
||||
clienteId: "optional-client-id"
|
||||
})
|
||||
// Returns: {
|
||||
// totalDispositivos, dispositivosOnline, dispositivosOffline,
|
||||
// dispositivosAlerta, alertasActivas, alertasCriticas
|
||||
// }
|
||||
```
|
||||
|
||||
## Equipos (PC/Laptop/Servidor)
|
||||
|
||||
### Listar Equipos
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/equipos.list
|
||||
const { dispositivos, pagination } = await trpc.equipos.list.query({
|
||||
clienteId: "client-id",
|
||||
tipo: "SERVIDOR", // PC | LAPTOP | SERVIDOR
|
||||
estado: "ONLINE", // ONLINE | OFFLINE | ALERTA | MANTENIMIENTO
|
||||
search: "term",
|
||||
page: 1,
|
||||
limit: 20
|
||||
})
|
||||
```
|
||||
|
||||
### Obtener Equipo
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/equipos.byId
|
||||
const equipo = await trpc.equipos.byId.query({ id: "device-id" })
|
||||
// Incluye: cliente, ubicacion, software, alertas activas
|
||||
```
|
||||
|
||||
### Obtener Metricas
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/equipos.metricas
|
||||
const metricas = await trpc.equipos.metricas.query({
|
||||
dispositivoId: "device-id",
|
||||
periodo: "24h" // 1h | 6h | 24h | 7d | 30d
|
||||
})
|
||||
```
|
||||
|
||||
### Iniciar Sesion Remota
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/equipos.iniciarSesion
|
||||
const { sesionId, url } = await trpc.equipos.iniciarSesion.mutate({
|
||||
dispositivoId: "device-id",
|
||||
tipo: "desktop" // desktop | terminal | files
|
||||
})
|
||||
// Redirigir a `url` para abrir conexion
|
||||
```
|
||||
|
||||
### Ejecutar Comando
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/equipos.ejecutarComando
|
||||
const resultado = await trpc.equipos.ejecutarComando.mutate({
|
||||
dispositivoId: "device-id",
|
||||
comando: "Get-Process",
|
||||
tipo: "powershell" // powershell | cmd | bash
|
||||
})
|
||||
// Returns: { success: true, output: "..." }
|
||||
```
|
||||
|
||||
### Reiniciar/Apagar
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/equipos.reiniciar
|
||||
await trpc.equipos.reiniciar.mutate({ dispositivoId: "device-id" })
|
||||
|
||||
// POST /api/trpc/equipos.apagar
|
||||
await trpc.equipos.apagar.mutate({ dispositivoId: "device-id" })
|
||||
```
|
||||
|
||||
## Celulares (MDM)
|
||||
|
||||
### Listar Celulares
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/celulares.list
|
||||
const { dispositivos, pagination } = await trpc.celulares.list.query({
|
||||
clienteId: "client-id",
|
||||
estado: "ONLINE",
|
||||
search: "term"
|
||||
})
|
||||
```
|
||||
|
||||
### Obtener Ubicacion
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/celulares.ubicacion
|
||||
const { lat, lng, updatedAt } = await trpc.celulares.ubicacion.query({
|
||||
dispositivoId: "device-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Solicitar Ubicacion
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/celulares.solicitarUbicacion
|
||||
await trpc.celulares.solicitarUbicacion.mutate({
|
||||
dispositivoId: "device-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Bloquear/Desbloquear
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/celulares.bloquear
|
||||
await trpc.celulares.bloquear.mutate({
|
||||
dispositivoId: "device-id",
|
||||
mensaje: "Dispositivo bloqueado por seguridad"
|
||||
})
|
||||
|
||||
// POST /api/trpc/celulares.desbloquear
|
||||
await trpc.celulares.desbloquear.mutate({
|
||||
dispositivoId: "device-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Hacer Sonar
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/celulares.sonar
|
||||
await trpc.celulares.sonar.mutate({
|
||||
dispositivoId: "device-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Enviar Mensaje
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/celulares.enviarMensaje
|
||||
await trpc.celulares.enviarMensaje.mutate({
|
||||
dispositivoId: "device-id",
|
||||
mensaje: "Mensaje para el usuario"
|
||||
})
|
||||
```
|
||||
|
||||
### Borrar Datos (Factory Reset)
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/celulares.borrarDatos
|
||||
// CUIDADO: Esta accion es irreversible
|
||||
await trpc.celulares.borrarDatos.mutate({
|
||||
dispositivoId: "device-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Instalar/Desinstalar App
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/celulares.instalarApp
|
||||
await trpc.celulares.instalarApp.mutate({
|
||||
dispositivoId: "device-id",
|
||||
packageName: "com.example.app"
|
||||
})
|
||||
|
||||
// POST /api/trpc/celulares.desinstalarApp
|
||||
await trpc.celulares.desinstalarApp.mutate({
|
||||
dispositivoId: "device-id",
|
||||
packageName: "com.example.app"
|
||||
})
|
||||
```
|
||||
|
||||
## Red (SNMP/NetFlow)
|
||||
|
||||
### Listar Dispositivos de Red
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/red.list
|
||||
const { dispositivos, pagination } = await trpc.red.list.query({
|
||||
clienteId: "client-id",
|
||||
tipo: "ROUTER", // ROUTER | SWITCH | FIREWALL | AP | IMPRESORA | OTRO
|
||||
estado: "ONLINE"
|
||||
})
|
||||
```
|
||||
|
||||
### Obtener Interfaces
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/red.interfaces
|
||||
const interfaces = await trpc.red.interfaces.query({
|
||||
dispositivoId: "device-id"
|
||||
})
|
||||
// Returns: [{ ifName, ifAlias, ifSpeed, ifOperStatus, ifInOctets_rate, ifOutOctets_rate }]
|
||||
```
|
||||
|
||||
### Obtener Trafico
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/red.trafico
|
||||
const datos = await trpc.red.trafico.query({
|
||||
dispositivoId: "device-id",
|
||||
portId: 123,
|
||||
periodo: "24h"
|
||||
})
|
||||
// Returns: [{ timestamp, in, out }]
|
||||
```
|
||||
|
||||
### Obtener Topologia
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/red.topologia
|
||||
const { nodes, links } = await trpc.red.topologia.query({
|
||||
clienteId: "client-id"
|
||||
})
|
||||
// Para visualizacion con D3.js o similar
|
||||
```
|
||||
|
||||
## Alertas
|
||||
|
||||
### Listar Alertas
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/alertas.list
|
||||
const { alertas, pagination } = await trpc.alertas.list.query({
|
||||
clienteId: "client-id",
|
||||
estado: "ACTIVA", // ACTIVA | RECONOCIDA | RESUELTA
|
||||
severidad: "CRITICAL", // INFO | WARNING | CRITICAL
|
||||
dispositivoId: "device-id",
|
||||
desde: new Date("2024-01-01"),
|
||||
hasta: new Date("2024-01-31")
|
||||
})
|
||||
```
|
||||
|
||||
### Reconocer Alerta
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/alertas.reconocer
|
||||
await trpc.alertas.reconocer.mutate({ id: "alert-id" })
|
||||
```
|
||||
|
||||
### Resolver Alerta
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/alertas.resolver
|
||||
await trpc.alertas.resolver.mutate({ id: "alert-id" })
|
||||
```
|
||||
|
||||
### Conteo de Alertas Activas
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/alertas.conteoActivas
|
||||
const { total, critical, warning, info } = await trpc.alertas.conteoActivas.query({
|
||||
clienteId: "client-id"
|
||||
})
|
||||
```
|
||||
|
||||
## Reglas de Alerta
|
||||
|
||||
### Listar Reglas
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/alertas.reglas.list
|
||||
const reglas = await trpc.alertas.reglas.list.query({
|
||||
clienteId: "client-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Crear Regla
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/alertas.reglas.create
|
||||
const regla = await trpc.alertas.reglas.create.mutate({
|
||||
clienteId: "client-id", // null para regla global
|
||||
nombre: "CPU Alta",
|
||||
metrica: "cpu",
|
||||
operador: ">",
|
||||
umbral: 90,
|
||||
duracionMinutos: 5,
|
||||
severidad: "WARNING",
|
||||
notificarEmail: true,
|
||||
notificarSms: false
|
||||
})
|
||||
```
|
||||
|
||||
## Reportes
|
||||
|
||||
### Reporte de Inventario
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/reportes.inventario
|
||||
const { dispositivos, resumen } = await trpc.reportes.inventario.query({
|
||||
clienteId: "client-id",
|
||||
tipo: "SERVIDOR"
|
||||
})
|
||||
```
|
||||
|
||||
### Reporte de Uptime
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/reportes.uptime
|
||||
const { dispositivos, promedioGeneral } = await trpc.reportes.uptime.query({
|
||||
clienteId: "client-id",
|
||||
desde: new Date("2024-01-01"),
|
||||
hasta: new Date("2024-01-31")
|
||||
})
|
||||
```
|
||||
|
||||
### Reporte de Alertas
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/reportes.alertas
|
||||
const { alertas, resumen } = await trpc.reportes.alertas.query({
|
||||
clienteId: "client-id",
|
||||
desde: new Date("2024-01-01"),
|
||||
hasta: new Date("2024-01-31")
|
||||
})
|
||||
```
|
||||
|
||||
### Exportar a CSV
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/reportes.exportarCSV
|
||||
const { filename, content, contentType } = await trpc.reportes.exportarCSV.mutate({
|
||||
tipo: "inventario", // inventario | alertas | actividad
|
||||
clienteId: "client-id"
|
||||
})
|
||||
// Descargar el contenido como archivo CSV
|
||||
```
|
||||
|
||||
## Usuarios
|
||||
|
||||
### Listar Usuarios
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/usuarios.list
|
||||
const { usuarios, pagination } = await trpc.usuarios.list.query({
|
||||
clienteId: "client-id",
|
||||
rol: "TECNICO",
|
||||
activo: true
|
||||
})
|
||||
```
|
||||
|
||||
### Crear Usuario
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/usuarios.create
|
||||
const usuario = await trpc.usuarios.create.mutate({
|
||||
email: "nuevo@example.com",
|
||||
nombre: "Nuevo Usuario",
|
||||
password: "password123",
|
||||
clienteId: "client-id",
|
||||
rol: "TECNICO"
|
||||
})
|
||||
```
|
||||
|
||||
### Resetear Password
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/usuarios.resetPassword
|
||||
await trpc.usuarios.resetPassword.mutate({
|
||||
id: "user-id",
|
||||
newPassword: "newpassword123"
|
||||
})
|
||||
```
|
||||
|
||||
## Configuracion
|
||||
|
||||
### Obtener Configuracion
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/configuracion.get
|
||||
const config = await trpc.configuracion.get.query({
|
||||
clave: "smtp_host"
|
||||
})
|
||||
```
|
||||
|
||||
### Establecer Configuracion
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/configuracion.set
|
||||
await trpc.configuracion.set.mutate({
|
||||
clave: "smtp_host",
|
||||
valor: "smtp.gmail.com",
|
||||
tipo: "string",
|
||||
categoria: "notificacion"
|
||||
})
|
||||
```
|
||||
|
||||
### Estado de Integraciones
|
||||
|
||||
```typescript
|
||||
// GET /api/trpc/configuracion.integraciones.status
|
||||
const { meshcentral, librenms, headwind } = await trpc.configuracion.integraciones.status.query()
|
||||
// Returns: { configurado: boolean, url?: string }
|
||||
```
|
||||
|
||||
### Configurar MeshCentral
|
||||
|
||||
```typescript
|
||||
// POST /api/trpc/configuracion.integraciones.setMeshCentral
|
||||
await trpc.configuracion.integraciones.setMeshCentral.mutate({
|
||||
url: "https://mesh.tudominio.com",
|
||||
user: "admin",
|
||||
password: "password",
|
||||
domain: "default"
|
||||
})
|
||||
```
|
||||
|
||||
## Codigos de Error
|
||||
|
||||
| Codigo | Descripcion |
|
||||
|--------|-------------|
|
||||
| UNAUTHORIZED | No autenticado |
|
||||
| FORBIDDEN | Sin permisos |
|
||||
| NOT_FOUND | Recurso no encontrado |
|
||||
| CONFLICT | Conflicto (ej: email duplicado) |
|
||||
| BAD_REQUEST | Request invalido |
|
||||
| INTERNAL_SERVER_ERROR | Error del servidor |
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- API general: 100 requests/minuto
|
||||
- Autenticacion: 10 requests/minuto
|
||||
- Acciones sensibles (wipe, commands): 5 requests/minuto
|
||||
|
||||
## Webhooks
|
||||
|
||||
Las alertas pueden enviar webhooks a URLs configuradas:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "alert-id",
|
||||
"severidad": "CRITICAL",
|
||||
"titulo": "Servidor offline",
|
||||
"mensaje": "El servidor SRV-01 no responde",
|
||||
"dispositivo": "SRV-01",
|
||||
"cliente": "Cliente A",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
321
docs/arquitectura/README.md
Normal file
321
docs/arquitectura/README.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Arquitectura del Sistema
|
||||
|
||||
## Vision General
|
||||
|
||||
MSP Monitor Dashboard es una aplicacion web moderna construida con una arquitectura monolitica modular, diseñada para ejecutarse en una unica VM de Proxmox mientras mantiene una clara separacion de responsabilidades.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NGINX (Proxy) │
|
||||
│ SSL/TLS + Rate Limiting │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌───────────────────────────────┐
|
||||
│ Next.js Dashboard │ │ Background Workers │
|
||||
│ (Frontend + API Routes) │ │ (BullMQ Jobs) │
|
||||
└───────────────────────────┘ └───────────────────────────────┘
|
||||
│ │
|
||||
└───────────────┬───────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ PostgreSQL │ │ Redis │ │ External │
|
||||
│ (Primary) │ │ (Cache/Queue) │ │ APIs │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
│
|
||||
┌───────────────────────────────┼───────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ MeshCentral │ │ LibreNMS │ │ Headwind MDM │
|
||||
│ (PC/Laptop) │ │ (Network) │ │ (Mobile) │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
## Componentes Principales
|
||||
|
||||
### 1. Frontend (Next.js + React)
|
||||
|
||||
**Ubicacion**: `src/app/`, `src/components/`
|
||||
|
||||
El frontend utiliza Next.js 14 con App Router, proporcionando:
|
||||
|
||||
- **Server Components**: Para renderizado del lado del servidor
|
||||
- **Client Components**: Para interactividad en el navegador
|
||||
- **Tailwind CSS**: Sistema de diseño con tema oscuro cyan/navy
|
||||
- **React Query**: Cache y sincronizacion de estado del servidor
|
||||
|
||||
**Estructura de paginas**:
|
||||
```
|
||||
src/app/
|
||||
├── (dashboard)/ # Grupo de rutas autenticadas
|
||||
│ ├── layout.tsx # Layout con sidebar y header
|
||||
│ ├── page.tsx # Dashboard principal
|
||||
│ ├── equipos/ # Gestion de PCs/laptops/servidores
|
||||
│ ├── celulares/ # Gestion de dispositivos moviles
|
||||
│ ├── red/ # Gestion de dispositivos de red
|
||||
│ ├── alertas/ # Centro de alertas
|
||||
│ ├── reportes/ # Generacion de reportes
|
||||
│ └── configuracion/ # Configuracion del sistema
|
||||
└── api/ # API Routes
|
||||
└── trpc/ # Endpoint tRPC
|
||||
```
|
||||
|
||||
### 2. Backend (tRPC + Prisma)
|
||||
|
||||
**Ubicacion**: `src/server/trpc/`
|
||||
|
||||
El backend utiliza tRPC para APIs type-safe con validacion Zod:
|
||||
|
||||
```typescript
|
||||
// Ejemplo de router
|
||||
export const equiposRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.dispositivo.findMany({
|
||||
where: input.clienteId ? { clienteId: input.clienteId } : {},
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
**Routers disponibles**:
|
||||
- `auth` - Autenticacion y sesiones
|
||||
- `clientes` - Gestion de clientes/tenants
|
||||
- `equipos` - PCs, laptops, servidores
|
||||
- `celulares` - Dispositivos moviles
|
||||
- `red` - Dispositivos de red
|
||||
- `alertas` - Sistema de alertas
|
||||
- `reportes` - Generacion de reportes
|
||||
- `usuarios` - Gestion de usuarios
|
||||
- `configuracion` - Configuracion del sistema
|
||||
|
||||
### 3. Servicios de Integracion
|
||||
|
||||
**Ubicacion**: `src/server/services/`
|
||||
|
||||
Clientes para comunicacion con sistemas externos:
|
||||
|
||||
```
|
||||
services/
|
||||
├── meshcentral/
|
||||
│ └── client.ts # Cliente API REST + WebSocket
|
||||
├── librenms/
|
||||
│ └── client.ts # Cliente API REST
|
||||
└── headwind/
|
||||
└── client.ts # Cliente API REST
|
||||
```
|
||||
|
||||
Cada cliente proporciona:
|
||||
- Autenticacion automatica
|
||||
- Manejo de errores
|
||||
- Tipado TypeScript
|
||||
- Cache de conexiones
|
||||
|
||||
### 4. Sistema de Jobs (BullMQ)
|
||||
|
||||
**Ubicacion**: `src/server/jobs/`
|
||||
|
||||
Procesamiento asincronico con BullMQ y Redis:
|
||||
|
||||
```
|
||||
jobs/
|
||||
├── queue.ts # Configuracion de colas
|
||||
├── worker.ts # Worker principal
|
||||
├── sync-meshcentral.job.ts
|
||||
├── sync-librenms.job.ts
|
||||
├── sync-headwind.job.ts
|
||||
├── alert-processor.job.ts
|
||||
├── notification.job.ts
|
||||
└── maintenance.job.ts
|
||||
```
|
||||
|
||||
**Colas**:
|
||||
| Cola | Proposito | Frecuencia |
|
||||
|------|-----------|------------|
|
||||
| sync | Sincronizacion con plataformas | Cada 5 min |
|
||||
| alerts | Procesamiento de alertas | Bajo demanda |
|
||||
| notifications | Envio de notificaciones | Bajo demanda |
|
||||
| maintenance | Tareas de mantenimiento | Diario/Horario |
|
||||
|
||||
### 5. Base de Datos (PostgreSQL + Prisma)
|
||||
|
||||
**Ubicacion**: `prisma/schema.prisma`
|
||||
|
||||
Modelo de datos multi-tenant:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐
|
||||
│ Cliente │────<│ Dispositivo │
|
||||
└─────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ ┌───────┴───────┐
|
||||
│ ▼ ▼
|
||||
│ ┌───────────────┐ ┌───────────────┐
|
||||
│ │ Metrica │ │ Software │
|
||||
│ └───────────────┘ └───────────────┘
|
||||
│
|
||||
├───<│ Usuario │
|
||||
│
|
||||
└───<│ Alerta │
|
||||
```
|
||||
|
||||
**Tablas principales**:
|
||||
- `clientes` - Tenants del sistema
|
||||
- `dispositivos` - Todos los tipos de dispositivos
|
||||
- `alertas` - Alertas y eventos
|
||||
- `usuarios` - Usuarios y permisos
|
||||
- `configuracion` - Settings del sistema
|
||||
|
||||
## Flujo de Datos
|
||||
|
||||
### 1. Sincronizacion de Dispositivos
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Plataforma │────>│ Worker │────>│ PostgreSQL │
|
||||
│ Externa │ │ (BullMQ) │ │ │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
▲ │
|
||||
│ ▼
|
||||
│ ┌─────────────┐
|
||||
│ │ Redis │
|
||||
│ │ (Cache) │
|
||||
│ └─────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────┐
|
||||
└────────────│ Alerta │
|
||||
│ Processor │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 2. Consulta de Dispositivos
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Browser │────>│ Next.js │────>│ tRPC │
|
||||
│ │<────│ (SSR) │<────│ Router │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Prisma │
|
||||
│ Client │
|
||||
└─────────────┘
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Redis │ │ PostgreSQL │
|
||||
│ (Cache) │ │ │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### 3. Sesion Remota
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Usuario │────>│ Dashboard │────>│ MeshCentral │
|
||||
│ │ │ │ │ (iframe) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ Audit Log │<───────────┘
|
||||
│ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ WebSocket (MeshCentral) │
|
||||
│ Desktop / Terminal / Files │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Seguridad
|
||||
|
||||
### Autenticacion
|
||||
|
||||
- **MeshCentral SSO**: Integracion con autenticacion de MeshCentral
|
||||
- **JWT Sessions**: Tokens firmados con expiracion de 24h
|
||||
- **Password Hashing**: bcrypt con salt factor 12
|
||||
|
||||
### Autorizacion
|
||||
|
||||
```typescript
|
||||
// Roles jerarquicos
|
||||
enum RolUsuario {
|
||||
SUPER_ADMIN, // Acceso total
|
||||
ADMIN, // Admin de cliente
|
||||
TECNICO, // Operaciones
|
||||
CLIENTE, // Solo lectura
|
||||
VIEWER // Vista limitada
|
||||
}
|
||||
```
|
||||
|
||||
### Aislamiento de Datos
|
||||
|
||||
- Cada consulta filtra por `clienteId`
|
||||
- Middleware verifica permisos en cada request
|
||||
- Audit log de todas las acciones sensibles
|
||||
|
||||
## Escalabilidad
|
||||
|
||||
### Horizontal (futuro)
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ HAProxy │
|
||||
│ (LB) │
|
||||
└─────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ Node 1 │ │ Node 2 │ │ Node 3 │
|
||||
│ (Next.js) │ │ (Next.js) │ │ (Worker) │
|
||||
└───────────┘ └───────────┘ └───────────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ PostgreSQL│ │ Redis │ │ Redis │
|
||||
│ Primary │ │ Primary │ │ Replica │
|
||||
└───────────┘ └───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
### Vertical (actual)
|
||||
|
||||
- **CPU**: Escalable segun carga
|
||||
- **RAM**: Minimo 4GB recomendado
|
||||
- **Disco**: SSD para BD, puede usar storage separado para backups
|
||||
|
||||
## Monitoreo del Sistema
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Endpoints de health
|
||||
GET /health # Estado general
|
||||
GET /api/health # Estado de API
|
||||
```
|
||||
|
||||
### Metricas
|
||||
|
||||
- CPU/RAM/Disco del servidor
|
||||
- Latencia de BD
|
||||
- Tamano de colas
|
||||
- Errores de sincronizacion
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Ver logs de contenedores
|
||||
docker-compose logs -f dashboard
|
||||
docker-compose logs -f worker
|
||||
```
|
||||
332
docs/guias/configuracion.md
Normal file
332
docs/guias/configuracion.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Guia de Configuracion
|
||||
|
||||
## Configuracion General
|
||||
|
||||
### Variables de Entorno
|
||||
|
||||
El archivo `.env` contiene toda la configuracion del sistema:
|
||||
|
||||
```env
|
||||
# ==================== BASE DE DATOS ====================
|
||||
# URL de conexion a PostgreSQL
|
||||
DATABASE_URL="postgresql://usuario:password@host:5432/database?schema=public"
|
||||
|
||||
# Credenciales para Docker
|
||||
POSTGRES_USER=mspmonitor
|
||||
POSTGRES_PASSWORD=password-seguro
|
||||
POSTGRES_DB=msp_monitor
|
||||
|
||||
# ==================== CACHE Y COLAS ====================
|
||||
# URL de conexion a Redis
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# ==================== SEGURIDAD ====================
|
||||
# Clave secreta para JWT (minimo 32 caracteres)
|
||||
JWT_SECRET="clave-muy-segura-de-al-menos-32-caracteres"
|
||||
|
||||
# ==================== INTEGRACIONES ====================
|
||||
# MeshCentral
|
||||
MESHCENTRAL_URL="https://mesh.tudominio.com"
|
||||
MESHCENTRAL_USER="admin"
|
||||
MESHCENTRAL_PASS="password"
|
||||
MESHCENTRAL_DOMAIN="default"
|
||||
|
||||
# LibreNMS
|
||||
LIBRENMS_URL="https://librenms.tudominio.com"
|
||||
LIBRENMS_TOKEN="tu-token-api"
|
||||
|
||||
# Headwind MDM
|
||||
HEADWIND_URL="https://mdm.tudominio.com"
|
||||
HEADWIND_TOKEN="tu-token-api"
|
||||
|
||||
# ==================== NOTIFICACIONES ====================
|
||||
# SMTP
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="tu-email@gmail.com"
|
||||
SMTP_PASS="tu-app-password"
|
||||
SMTP_FROM="MSP Monitor <noreply@tudominio.com>"
|
||||
|
||||
# Twilio (opcional)
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_PHONE_NUMBER=""
|
||||
|
||||
# ==================== APLICACION ====================
|
||||
# URL publica de la aplicacion
|
||||
NEXT_PUBLIC_APP_URL="https://monitor.tudominio.com"
|
||||
|
||||
# Entorno
|
||||
NODE_ENV="production"
|
||||
```
|
||||
|
||||
## Configuracion de Integraciones
|
||||
|
||||
### MeshCentral
|
||||
|
||||
1. **Crear usuario API en MeshCentral**:
|
||||
- Acceder a MeshCentral como admin
|
||||
- Ir a "My Account" > "Security" > "Create Login Token"
|
||||
- O crear usuario dedicado con permisos de API
|
||||
|
||||
2. **Configurar en el dashboard**:
|
||||
- Ir a Configuracion > Integraciones
|
||||
- Ingresar URL, usuario y password
|
||||
- Probar conexion
|
||||
|
||||
3. **Mapear grupos**:
|
||||
- Cada cliente puede tener un grupo de MeshCentral asignado
|
||||
- Los dispositivos del grupo se sincronizaran automaticamente
|
||||
|
||||
### LibreNMS
|
||||
|
||||
1. **Crear API Token en LibreNMS**:
|
||||
- Ir a "Settings" > "API" > "API Settings"
|
||||
- Crear nuevo token con permisos de lectura
|
||||
|
||||
2. **Configurar en el dashboard**:
|
||||
- Ir a Configuracion > Integraciones
|
||||
- Ingresar URL y token
|
||||
- Probar conexion
|
||||
|
||||
3. **Mapear grupos de dispositivos**:
|
||||
- Crear grupos en LibreNMS para cada cliente
|
||||
- Asignar el grupo al cliente en el dashboard
|
||||
|
||||
### Headwind MDM
|
||||
|
||||
1. **Obtener API Token**:
|
||||
- Acceder a panel de admin de Headwind
|
||||
- Ir a configuracion de API
|
||||
- Copiar token de acceso
|
||||
|
||||
2. **Configurar en el dashboard**:
|
||||
- Ir a Configuracion > Integraciones
|
||||
- Ingresar URL y token
|
||||
- Probar conexion
|
||||
|
||||
## Configuracion de Notificaciones
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
Para Gmail:
|
||||
1. Habilitar autenticacion de dos factores
|
||||
2. Crear "App Password" en configuracion de seguridad
|
||||
3. Usar el app password en `SMTP_PASS`
|
||||
|
||||
```env
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="tu-cuenta@gmail.com"
|
||||
SMTP_PASS="xxxx-xxxx-xxxx-xxxx" # App password
|
||||
SMTP_FROM="MSP Monitor <noreply@tudominio.com>"
|
||||
```
|
||||
|
||||
Para otros proveedores:
|
||||
|
||||
| Proveedor | Host | Puerto |
|
||||
|-----------|------|--------|
|
||||
| Gmail | smtp.gmail.com | 587 |
|
||||
| Office 365 | smtp.office365.com | 587 |
|
||||
| SendGrid | smtp.sendgrid.net | 587 |
|
||||
| Mailgun | smtp.mailgun.org | 587 |
|
||||
|
||||
### SMS (Twilio)
|
||||
|
||||
1. Crear cuenta en Twilio
|
||||
2. Obtener Account SID y Auth Token
|
||||
3. Comprar numero de telefono
|
||||
4. Configurar en `.env`:
|
||||
|
||||
```env
|
||||
TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
TWILIO_PHONE_NUMBER="+1234567890"
|
||||
```
|
||||
|
||||
### Webhooks
|
||||
|
||||
Los webhooks se configuran por regla de alerta:
|
||||
|
||||
1. Crear regla de alerta
|
||||
2. Habilitar "Notificar por Webhook"
|
||||
3. Ingresar URL del endpoint
|
||||
|
||||
Formato del payload:
|
||||
```json
|
||||
{
|
||||
"id": "alert-id",
|
||||
"severidad": "CRITICAL",
|
||||
"titulo": "Titulo de la alerta",
|
||||
"mensaje": "Descripcion detallada",
|
||||
"dispositivo": "Nombre del dispositivo",
|
||||
"cliente": "Nombre del cliente",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuracion de Usuarios y Permisos
|
||||
|
||||
### Roles de Usuario
|
||||
|
||||
| Rol | Descripcion | Permisos |
|
||||
|-----|-------------|----------|
|
||||
| SUPER_ADMIN | Administrador global | Todo |
|
||||
| ADMIN | Admin de cliente | Gestion de su cliente |
|
||||
| TECNICO | Soporte tecnico | Operaciones y monitoreo |
|
||||
| CLIENTE | Usuario cliente | Solo lectura |
|
||||
| VIEWER | Vista limitada | Dashboard basico |
|
||||
|
||||
### Crear Usuario
|
||||
|
||||
1. Ir a Configuracion > Usuarios
|
||||
2. Click en "Nuevo Usuario"
|
||||
3. Completar:
|
||||
- Email
|
||||
- Nombre
|
||||
- Rol
|
||||
- Cliente (opcional)
|
||||
- Password (o usar SSO de MeshCentral)
|
||||
|
||||
### Permisos Granulares
|
||||
|
||||
Ademas del rol, se pueden asignar permisos especificos:
|
||||
|
||||
```
|
||||
recursos: dispositivos, alertas, reportes, configuracion, usuarios
|
||||
acciones: read, write, delete, execute
|
||||
```
|
||||
|
||||
## Configuracion de Alertas
|
||||
|
||||
### Reglas Predefinidas
|
||||
|
||||
El sistema incluye reglas por defecto:
|
||||
|
||||
| Regla | Metrica | Umbral | Severidad |
|
||||
|-------|---------|--------|-----------|
|
||||
| CPU Alta | cpu | > 90% | WARNING |
|
||||
| CPU Critica | cpu | > 95% | CRITICAL |
|
||||
| RAM Alta | ram | > 85% | WARNING |
|
||||
| Disco Lleno | disco | > 90% | WARNING |
|
||||
| Temperatura Alta | temperatura | > 80 | WARNING |
|
||||
| Bateria Baja | bateria | < 15 | WARNING |
|
||||
|
||||
### Crear Regla Personalizada
|
||||
|
||||
1. Ir a Alertas > Reglas
|
||||
2. Click en "Nueva Regla"
|
||||
3. Configurar:
|
||||
- Nombre descriptivo
|
||||
- Tipo de dispositivo (opcional)
|
||||
- Metrica a monitorear
|
||||
- Condicion (>, <, >=, <=, ==)
|
||||
- Valor umbral
|
||||
- Duracion minima (evitar falsos positivos)
|
||||
- Severidad
|
||||
- Canales de notificacion
|
||||
|
||||
## Configuracion de Backups
|
||||
|
||||
### Backup Automatico
|
||||
|
||||
Agregar al crontab del servidor:
|
||||
|
||||
```bash
|
||||
# Backup diario a las 2am
|
||||
0 2 * * * /opt/msp-monitor/scripts/backup-db.sh >> /var/log/msp-backup.log 2>&1
|
||||
```
|
||||
|
||||
### Configuracion de Backup
|
||||
|
||||
Variables en `.env`:
|
||||
|
||||
```env
|
||||
# Directorio de backups
|
||||
BACKUP_DIR="/backups"
|
||||
|
||||
# Dias de retencion
|
||||
RETENTION_DAYS=30
|
||||
|
||||
# S3 (opcional)
|
||||
S3_BUCKET="mi-bucket-backups"
|
||||
AWS_ACCESS_KEY_ID="..."
|
||||
AWS_SECRET_ACCESS_KEY="..."
|
||||
```
|
||||
|
||||
### Restaurar Backup
|
||||
|
||||
```bash
|
||||
./scripts/restore-db.sh /backups/msp_monitor_20240115_020000.sql.gz
|
||||
```
|
||||
|
||||
## Configuracion de SSL
|
||||
|
||||
### Renovacion Automatica
|
||||
|
||||
El contenedor de Certbot renueva automaticamente. Verificar:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml logs certbot
|
||||
```
|
||||
|
||||
### Certificado Manual
|
||||
|
||||
1. Copiar certificados a `docker/nginx/ssl/`
|
||||
2. Actualizar paths en `docker/nginx/conf.d/default.conf`
|
||||
3. Reiniciar Nginx: `docker compose restart nginx`
|
||||
|
||||
## Configuracion de Logs
|
||||
|
||||
### Niveles de Log
|
||||
|
||||
En `.env`:
|
||||
```env
|
||||
# Nivel de log: debug, info, warn, error
|
||||
LOG_LEVEL="info"
|
||||
```
|
||||
|
||||
### Rotacion de Logs
|
||||
|
||||
Docker maneja la rotacion. Configurar en `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "10m",
|
||||
"max-file": "3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Personalizacion
|
||||
|
||||
### Tema Visual
|
||||
|
||||
Editar `tailwind.config.ts` para cambiar colores:
|
||||
|
||||
```typescript
|
||||
colors: {
|
||||
primary: {
|
||||
500: '#06b6d4', // Color principal
|
||||
// ...
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Logo
|
||||
|
||||
Reemplazar archivo en `public/logo.png`
|
||||
|
||||
### Titulo
|
||||
|
||||
Editar `src/app/layout.tsx`:
|
||||
|
||||
```typescript
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mi Dashboard MSP',
|
||||
// ...
|
||||
}
|
||||
```
|
||||
359
docs/guias/instalacion.md
Normal file
359
docs/guias/instalacion.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Guia de Instalacion
|
||||
|
||||
## Requisitos del Sistema
|
||||
|
||||
### Hardware Minimo
|
||||
|
||||
| Componente | Minimo | Recomendado |
|
||||
|------------|--------|-------------|
|
||||
| CPU | 2 cores | 4+ cores |
|
||||
| RAM | 4 GB | 8+ GB |
|
||||
| Disco | 40 GB SSD | 100+ GB SSD |
|
||||
| Red | 100 Mbps | 1 Gbps |
|
||||
|
||||
### Software
|
||||
|
||||
- **Sistema Operativo**: Ubuntu 22.04 LTS o Debian 12
|
||||
- **Docker**: 24.0+
|
||||
- **Docker Compose**: 2.20+
|
||||
- **Node.js**: 20.x (solo para desarrollo)
|
||||
|
||||
### Requisitos de Red
|
||||
|
||||
- Puerto 80 (HTTP)
|
||||
- Puerto 443 (HTTPS)
|
||||
- Puerto 5432 (PostgreSQL, solo interno)
|
||||
- Puerto 6379 (Redis, solo interno)
|
||||
- Acceso a MeshCentral, LibreNMS y/o Headwind MDM
|
||||
|
||||
## Instalacion en Produccion
|
||||
|
||||
### 1. Preparar el Servidor
|
||||
|
||||
```bash
|
||||
# Actualizar sistema
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Instalar dependencias
|
||||
sudo apt install -y curl git
|
||||
|
||||
# Instalar Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# Agregar usuario al grupo docker
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Instalar Docker Compose
|
||||
sudo apt install -y docker-compose-plugin
|
||||
```
|
||||
|
||||
### 2. Clonar el Repositorio
|
||||
|
||||
```bash
|
||||
# Crear directorio
|
||||
sudo mkdir -p /opt/msp-monitor
|
||||
cd /opt/msp-monitor
|
||||
|
||||
# Clonar repositorio
|
||||
git clone https://git.consultoria-as.com/msp/msp-monitor-dashboard.git .
|
||||
|
||||
# Configurar permisos
|
||||
sudo chown -R $USER:$USER /opt/msp-monitor
|
||||
```
|
||||
|
||||
### 3. Configurar Variables de Entorno
|
||||
|
||||
```bash
|
||||
# Copiar archivo de ejemplo
|
||||
cp .env.example .env
|
||||
|
||||
# Editar configuracion
|
||||
nano .env
|
||||
```
|
||||
|
||||
Configuracion minima requerida:
|
||||
|
||||
```env
|
||||
# Seguridad - CAMBIAR ESTOS VALORES
|
||||
POSTGRES_PASSWORD=<password-seguro>
|
||||
JWT_SECRET=<clave-jwt-minimo-32-caracteres>
|
||||
|
||||
# MeshCentral (opcional si no se usa)
|
||||
MESHCENTRAL_URL=https://mesh.tudominio.com
|
||||
MESHCENTRAL_USER=admin
|
||||
MESHCENTRAL_PASS=password
|
||||
MESHCENTRAL_DOMAIN=default
|
||||
|
||||
# LibreNMS (opcional si no se usa)
|
||||
LIBRENMS_URL=https://librenms.tudominio.com
|
||||
LIBRENMS_TOKEN=tu-api-token
|
||||
|
||||
# Headwind MDM (opcional si no se usa)
|
||||
HEADWIND_URL=https://mdm.tudominio.com
|
||||
HEADWIND_TOKEN=tu-api-token
|
||||
|
||||
# Email (para notificaciones)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=tu-email@gmail.com
|
||||
SMTP_PASS=tu-app-password
|
||||
SMTP_FROM=MSP Monitor <noreply@tudominio.com>
|
||||
|
||||
# URL de la aplicacion
|
||||
APP_URL=https://monitor.tudominio.com
|
||||
```
|
||||
|
||||
### 4. Configurar SSL
|
||||
|
||||
#### Opcion A: Certificado Let's Encrypt (recomendado)
|
||||
|
||||
```bash
|
||||
# Crear directorios
|
||||
mkdir -p docker/nginx/ssl
|
||||
|
||||
# Iniciar Nginx sin SSL primero
|
||||
docker compose -f docker/docker-compose.yml up -d nginx
|
||||
|
||||
# Obtener certificado
|
||||
docker compose -f docker/docker-compose.yml run --rm certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
-d monitor.tudominio.com \
|
||||
--email tu-email@tudominio.com \
|
||||
--agree-tos \
|
||||
--no-eff-email
|
||||
|
||||
# Reiniciar con SSL
|
||||
docker compose -f docker/docker-compose.yml restart nginx
|
||||
```
|
||||
|
||||
#### Opcion B: Certificado Propio
|
||||
|
||||
```bash
|
||||
# Copiar certificados
|
||||
cp /ruta/certificado.crt docker/nginx/ssl/live/monitor.tudominio.com/fullchain.pem
|
||||
cp /ruta/certificado.key docker/nginx/ssl/live/monitor.tudominio.com/privkey.pem
|
||||
```
|
||||
|
||||
### 5. Configurar Nginx
|
||||
|
||||
Editar `docker/nginx/conf.d/default.conf`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name monitor.tudominio.com; # Cambiar por tu dominio
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/live/monitor.tudominio.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/live/monitor.tudominio.com/privkey.pem;
|
||||
|
||||
# ... resto de configuracion
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Iniciar Servicios
|
||||
|
||||
```bash
|
||||
# Construir imagenes
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
# Iniciar servicios
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Verificar estado
|
||||
docker compose -f docker/docker-compose.yml ps
|
||||
```
|
||||
|
||||
### 7. Aplicar Migraciones
|
||||
|
||||
```bash
|
||||
# Ejecutar migraciones
|
||||
docker compose -f docker/docker-compose.yml exec dashboard npx prisma db push
|
||||
|
||||
# Verificar conexion a BD
|
||||
docker compose -f docker/docker-compose.yml exec dashboard npx prisma studio
|
||||
```
|
||||
|
||||
### 8. Crear Usuario Administrador
|
||||
|
||||
```bash
|
||||
# Conectar a la BD
|
||||
docker compose -f docker/docker-compose.yml exec postgres psql -U mspmonitor -d msp_monitor
|
||||
|
||||
# Crear usuario (dentro de psql)
|
||||
INSERT INTO usuarios (id, email, nombre, password_hash, rol, activo, created_at, updated_at)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
'admin@tudominio.com',
|
||||
'Administrador',
|
||||
'$2a$12$hash-del-password', -- Generar con bcrypt
|
||||
'SUPER_ADMIN',
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
```
|
||||
|
||||
O usando el script de setup interactivo:
|
||||
|
||||
```bash
|
||||
./scripts/setup.sh
|
||||
```
|
||||
|
||||
### 9. Verificar Instalacion
|
||||
|
||||
1. Acceder a `https://monitor.tudominio.com`
|
||||
2. Iniciar sesion con las credenciales creadas
|
||||
3. Verificar conexion a MeshCentral, LibreNMS, Headwind
|
||||
4. Revisar logs: `docker compose -f docker/docker-compose.yml logs -f`
|
||||
|
||||
## Instalacion en Desarrollo
|
||||
|
||||
### 1. Requisitos
|
||||
|
||||
- Node.js 20+
|
||||
- npm o yarn
|
||||
- Docker (para PostgreSQL y Redis)
|
||||
|
||||
### 2. Configurar Entorno
|
||||
|
||||
```bash
|
||||
# Clonar repositorio
|
||||
git clone https://git.consultoria-as.com/msp/msp-monitor-dashboard.git
|
||||
cd msp-monitor-dashboard
|
||||
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Configurar variables
|
||||
cp .env.example .env
|
||||
# Editar .env con configuracion local
|
||||
```
|
||||
|
||||
### 3. Iniciar Servicios de Base de Datos
|
||||
|
||||
```bash
|
||||
# Solo PostgreSQL y Redis
|
||||
docker compose -f docker/docker-compose.yml up -d postgres redis
|
||||
```
|
||||
|
||||
### 4. Configurar Base de Datos
|
||||
|
||||
```bash
|
||||
# Generar cliente Prisma
|
||||
npm run db:generate
|
||||
|
||||
# Aplicar schema
|
||||
npm run db:push
|
||||
|
||||
# (Opcional) Abrir Prisma Studio
|
||||
npm run db:studio
|
||||
```
|
||||
|
||||
### 5. Iniciar Desarrollo
|
||||
|
||||
```bash
|
||||
# Iniciar servidor de desarrollo
|
||||
npm run dev
|
||||
|
||||
# En otra terminal, iniciar workers (opcional)
|
||||
npm run jobs:start
|
||||
```
|
||||
|
||||
Acceder a `http://localhost:3000`
|
||||
|
||||
## Actualizacion
|
||||
|
||||
### Actualizacion en Produccion
|
||||
|
||||
```bash
|
||||
cd /opt/msp-monitor
|
||||
|
||||
# Detener servicios
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
|
||||
# Actualizar codigo
|
||||
git pull origin main
|
||||
|
||||
# Reconstruir imagenes
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
|
||||
# Iniciar servicios
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Aplicar migraciones si hay cambios de BD
|
||||
docker compose -f docker/docker-compose.yml exec dashboard npx prisma db push
|
||||
```
|
||||
|
||||
### Actualizacion en Desarrollo
|
||||
|
||||
```bash
|
||||
# Actualizar codigo
|
||||
git pull origin main
|
||||
|
||||
# Actualizar dependencias
|
||||
npm install
|
||||
|
||||
# Regenerar cliente Prisma
|
||||
npm run db:generate
|
||||
|
||||
# Aplicar migraciones
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
## Solucion de Problemas
|
||||
|
||||
### Error de Conexion a Base de Datos
|
||||
|
||||
```bash
|
||||
# Verificar que PostgreSQL esta corriendo
|
||||
docker compose -f docker/docker-compose.yml ps postgres
|
||||
|
||||
# Ver logs de PostgreSQL
|
||||
docker compose -f docker/docker-compose.yml logs postgres
|
||||
|
||||
# Verificar conectividad
|
||||
docker compose -f docker/docker-compose.yml exec postgres pg_isready
|
||||
```
|
||||
|
||||
### Error de Conexion a Redis
|
||||
|
||||
```bash
|
||||
# Verificar que Redis esta corriendo
|
||||
docker compose -f docker/docker-compose.yml ps redis
|
||||
|
||||
# Probar conexion
|
||||
docker compose -f docker/docker-compose.yml exec redis redis-cli ping
|
||||
```
|
||||
|
||||
### Dashboard no Carga
|
||||
|
||||
```bash
|
||||
# Ver logs del dashboard
|
||||
docker compose -f docker/docker-compose.yml logs dashboard
|
||||
|
||||
# Verificar variables de entorno
|
||||
docker compose -f docker/docker-compose.yml exec dashboard env
|
||||
|
||||
# Reiniciar servicio
|
||||
docker compose -f docker/docker-compose.yml restart dashboard
|
||||
```
|
||||
|
||||
### Workers no Procesan Jobs
|
||||
|
||||
```bash
|
||||
# Ver logs del worker
|
||||
docker compose -f docker/docker-compose.yml logs worker
|
||||
|
||||
# Verificar colas en Redis
|
||||
docker compose -f docker/docker-compose.yml exec redis redis-cli KEYS "bull:*"
|
||||
```
|
||||
|
||||
## Proximos Pasos
|
||||
|
||||
- [Configuracion del Sistema](configuracion.md)
|
||||
- [Integracion con MeshCentral](../integraciones/meshcentral.md)
|
||||
- [Integracion con LibreNMS](../integraciones/librenms.md)
|
||||
- [Integracion con Headwind MDM](../integraciones/headwind.md)
|
||||
17
next.config.js
Normal file
17
next.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "msp-monitor-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "Dashboard de monitoreo MSP - MeshCentral, LibreNMS, Headwind MDM",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"jobs:start": "tsx src/server/jobs/worker.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.0",
|
||||
"@tanstack/react-query": "^5.24.0",
|
||||
"@trpc/client": "^10.45.0",
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jose": "^5.2.0",
|
||||
"lucide-react": "^0.338.0",
|
||||
"next": "14.1.0",
|
||||
"nodemailer": "^6.9.9",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^2.12.0",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"twilio": "^4.21.0",
|
||||
"ws": "^8.16.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
"prisma": "^5.10.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
361
prisma/schema.prisma
Normal file
361
prisma/schema.prisma
Normal file
@@ -0,0 +1,361 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ==================== CLIENTES ====================
|
||||
model Cliente {
|
||||
id String @id @default(cuid())
|
||||
nombre String
|
||||
codigo String @unique
|
||||
email String?
|
||||
telefono String?
|
||||
activo Boolean @default(true)
|
||||
notas String?
|
||||
meshcentralGrupo String? @map("meshcentral_grupo")
|
||||
librenmsGrupo Int? @map("librenms_grupo")
|
||||
headwindGrupo Int? @map("headwind_grupo")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
ubicaciones ClienteUbicacion[]
|
||||
dispositivos Dispositivo[]
|
||||
usuarios Usuario[]
|
||||
alertas Alerta[]
|
||||
alertaReglas AlertaRegla[]
|
||||
|
||||
@@map("clientes")
|
||||
}
|
||||
|
||||
model ClienteUbicacion {
|
||||
id String @id @default(cuid())
|
||||
clienteId String @map("cliente_id")
|
||||
nombre String
|
||||
direccion String?
|
||||
ciudad String?
|
||||
estado String?
|
||||
pais String @default("Mexico")
|
||||
codigoPostal String? @map("codigo_postal")
|
||||
latitud Float?
|
||||
longitud Float?
|
||||
principal Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
|
||||
dispositivos Dispositivo[]
|
||||
|
||||
@@map("cliente_ubicaciones")
|
||||
}
|
||||
|
||||
// ==================== DISPOSITIVOS ====================
|
||||
enum TipoDispositivo {
|
||||
PC
|
||||
LAPTOP
|
||||
SERVIDOR
|
||||
CELULAR
|
||||
TABLET
|
||||
ROUTER
|
||||
SWITCH
|
||||
FIREWALL
|
||||
AP
|
||||
IMPRESORA
|
||||
OTRO
|
||||
}
|
||||
|
||||
enum EstadoDispositivo {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
ALERTA
|
||||
MANTENIMIENTO
|
||||
DESCONOCIDO
|
||||
}
|
||||
|
||||
model Dispositivo {
|
||||
id String @id @default(cuid())
|
||||
clienteId String @map("cliente_id")
|
||||
ubicacionId String? @map("ubicacion_id")
|
||||
tipo TipoDispositivo
|
||||
nombre String
|
||||
descripcion String?
|
||||
estado EstadoDispositivo @default(DESCONOCIDO)
|
||||
|
||||
// IDs externos
|
||||
meshcentralId String? @unique @map("meshcentral_id")
|
||||
librenmsId Int? @unique @map("librenms_id")
|
||||
headwindId Int? @unique @map("headwind_id")
|
||||
|
||||
// Info general
|
||||
ip String?
|
||||
mac String?
|
||||
sistemaOperativo String? @map("sistema_operativo")
|
||||
versionSO String? @map("version_so")
|
||||
fabricante String?
|
||||
modelo String?
|
||||
serial String?
|
||||
|
||||
// Para equipos de computo
|
||||
cpu String?
|
||||
ram Int? // En MB
|
||||
disco Int? // En GB
|
||||
|
||||
// Para celulares
|
||||
imei String?
|
||||
numeroTelefono String? @map("numero_telefono")
|
||||
operador String?
|
||||
|
||||
// Para red
|
||||
firmware String?
|
||||
snmpCommunity String? @map("snmp_community")
|
||||
|
||||
// Metricas actuales
|
||||
cpuUsage Float? @map("cpu_usage")
|
||||
ramUsage Float? @map("ram_usage")
|
||||
discoUsage Float? @map("disco_usage")
|
||||
temperatura Float?
|
||||
bateria Int?
|
||||
|
||||
// Ubicacion GPS (celulares)
|
||||
latitud Float?
|
||||
longitud Float?
|
||||
gpsUpdatedAt DateTime? @map("gps_updated_at")
|
||||
|
||||
// Timestamps
|
||||
lastSeen DateTime? @map("last_seen")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
|
||||
ubicacion ClienteUbicacion? @relation(fields: [ubicacionId], references: [id])
|
||||
software DispositivoSoftware[]
|
||||
metricas DispositivoMetrica[]
|
||||
metricasHourly DispositivoMetricaHourly[]
|
||||
alertas Alerta[]
|
||||
sesiones SesionRemota[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([clienteId])
|
||||
@@index([tipo])
|
||||
@@index([estado])
|
||||
@@map("dispositivos")
|
||||
}
|
||||
|
||||
model DispositivoSoftware {
|
||||
id String @id @default(cuid())
|
||||
dispositivoId String @map("dispositivo_id")
|
||||
nombre String
|
||||
version String?
|
||||
editor String?
|
||||
instaladoEn DateTime? @map("instalado_en")
|
||||
tamanio Int? // En MB
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([dispositivoId, nombre, version])
|
||||
@@map("dispositivo_software")
|
||||
}
|
||||
|
||||
model DispositivoMetrica {
|
||||
id String @id @default(cuid())
|
||||
dispositivoId String @map("dispositivo_id")
|
||||
timestamp DateTime @default(now())
|
||||
cpuUsage Float? @map("cpu_usage")
|
||||
ramUsage Float? @map("ram_usage")
|
||||
discoUsage Float? @map("disco_usage")
|
||||
temperatura Float?
|
||||
bateria Int?
|
||||
redIn BigInt? @map("red_in") // bytes
|
||||
redOut BigInt? @map("red_out") // bytes
|
||||
|
||||
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([dispositivoId, timestamp])
|
||||
@@map("dispositivo_metricas")
|
||||
}
|
||||
|
||||
model DispositivoMetricaHourly {
|
||||
id String @id @default(cuid())
|
||||
dispositivoId String @map("dispositivo_id")
|
||||
hora DateTime
|
||||
cpuAvg Float? @map("cpu_avg")
|
||||
cpuMax Float? @map("cpu_max")
|
||||
ramAvg Float? @map("ram_avg")
|
||||
ramMax Float? @map("ram_max")
|
||||
discoAvg Float? @map("disco_avg")
|
||||
tempAvg Float? @map("temp_avg")
|
||||
tempMax Float? @map("temp_max")
|
||||
redInTotal BigInt? @map("red_in_total")
|
||||
redOutTotal BigInt? @map("red_out_total")
|
||||
|
||||
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([dispositivoId, hora])
|
||||
@@map("dispositivo_metricas_hourly")
|
||||
}
|
||||
|
||||
// ==================== ALERTAS ====================
|
||||
enum SeveridadAlerta {
|
||||
INFO
|
||||
WARNING
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum EstadoAlerta {
|
||||
ACTIVA
|
||||
RECONOCIDA
|
||||
RESUELTA
|
||||
}
|
||||
|
||||
model Alerta {
|
||||
id String @id @default(cuid())
|
||||
clienteId String @map("cliente_id")
|
||||
dispositivoId String? @map("dispositivo_id")
|
||||
reglaId String? @map("regla_id")
|
||||
severidad SeveridadAlerta
|
||||
estado EstadoAlerta @default(ACTIVA)
|
||||
titulo String
|
||||
mensaje String
|
||||
origen String? // meshcentral, librenms, headwind, sistema
|
||||
origenId String? @map("origen_id")
|
||||
reconocidaPor String? @map("reconocida_por")
|
||||
reconocidaEn DateTime? @map("reconocida_en")
|
||||
resueltaPor String? @map("resuelta_por")
|
||||
resueltaEn DateTime? @map("resuelta_en")
|
||||
notificada Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
|
||||
dispositivo Dispositivo? @relation(fields: [dispositivoId], references: [id])
|
||||
regla AlertaRegla? @relation(fields: [reglaId], references: [id])
|
||||
|
||||
@@index([clienteId, estado])
|
||||
@@index([severidad])
|
||||
@@index([createdAt])
|
||||
@@map("alertas")
|
||||
}
|
||||
|
||||
model AlertaRegla {
|
||||
id String @id @default(cuid())
|
||||
clienteId String? @map("cliente_id") // null = global
|
||||
nombre String
|
||||
descripcion String?
|
||||
activa Boolean @default(true)
|
||||
tipoDispositivo TipoDispositivo? @map("tipo_dispositivo")
|
||||
metrica String // cpu, ram, disco, temperatura, etc
|
||||
operador String // >, <, >=, <=, ==
|
||||
umbral Float
|
||||
duracionMinutos Int @default(5) @map("duracion_minutos")
|
||||
severidad SeveridadAlerta
|
||||
notificarEmail Boolean @default(true) @map("notificar_email")
|
||||
notificarSms Boolean @default(false) @map("notificar_sms")
|
||||
notificarWebhook Boolean @default(false) @map("notificar_webhook")
|
||||
webhookUrl String? @map("webhook_url")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
cliente Cliente? @relation(fields: [clienteId], references: [id], onDelete: Cascade)
|
||||
alertas Alerta[]
|
||||
|
||||
@@map("alerta_reglas")
|
||||
}
|
||||
|
||||
// ==================== USUARIOS ====================
|
||||
enum RolUsuario {
|
||||
SUPER_ADMIN
|
||||
ADMIN
|
||||
TECNICO
|
||||
CLIENTE
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model Usuario {
|
||||
id String @id @default(cuid())
|
||||
clienteId String? @map("cliente_id") // null = acceso global
|
||||
email String @unique
|
||||
nombre String
|
||||
passwordHash String? @map("password_hash")
|
||||
meshcentralUser String? @unique @map("meshcentral_user")
|
||||
rol RolUsuario @default(VIEWER)
|
||||
activo Boolean @default(true)
|
||||
avatar String?
|
||||
telefono String?
|
||||
notificarEmail Boolean @default(true) @map("notificar_email")
|
||||
notificarSms Boolean @default(false) @map("notificar_sms")
|
||||
lastLogin DateTime? @map("last_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
cliente Cliente? @relation(fields: [clienteId], references: [id], onDelete: SetNull)
|
||||
permisos UsuarioPermiso[]
|
||||
sesiones SesionRemota[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("usuarios")
|
||||
}
|
||||
|
||||
model UsuarioPermiso {
|
||||
id String @id @default(cuid())
|
||||
usuarioId String @map("usuario_id")
|
||||
recurso String // dispositivos, alertas, reportes, configuracion, etc
|
||||
accion String // read, write, delete, execute
|
||||
permitido Boolean @default(true)
|
||||
|
||||
usuario Usuario @relation(fields: [usuarioId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([usuarioId, recurso, accion])
|
||||
@@map("usuario_permisos")
|
||||
}
|
||||
|
||||
// ==================== SESIONES Y AUDITORIA ====================
|
||||
model SesionRemota {
|
||||
id String @id @default(cuid())
|
||||
usuarioId String @map("usuario_id")
|
||||
dispositivoId String @map("dispositivo_id")
|
||||
tipo String // desktop, terminal, files
|
||||
meshSessionId String? @map("mesh_session_id")
|
||||
iniciadaEn DateTime @default(now()) @map("iniciada_en")
|
||||
finalizadaEn DateTime? @map("finalizada_en")
|
||||
duracion Int? // segundos
|
||||
ip String?
|
||||
|
||||
usuario Usuario @relation(fields: [usuarioId], references: [id])
|
||||
dispositivo Dispositivo @relation(fields: [dispositivoId], references: [id])
|
||||
|
||||
@@map("sesiones_remotas")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
usuarioId String? @map("usuario_id")
|
||||
dispositivoId String? @map("dispositivo_id")
|
||||
accion String
|
||||
recurso String
|
||||
detalles Json?
|
||||
ip String?
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
usuario Usuario? @relation(fields: [usuarioId], references: [id])
|
||||
dispositivo Dispositivo? @relation(fields: [dispositivoId], references: [id])
|
||||
|
||||
@@index([usuarioId])
|
||||
@@index([createdAt])
|
||||
@@map("audit_log")
|
||||
}
|
||||
|
||||
// ==================== CONFIGURACION ====================
|
||||
model Configuracion {
|
||||
id String @id @default(cuid())
|
||||
clave String @unique
|
||||
valor Json
|
||||
tipo String // string, number, boolean, json
|
||||
categoria String // general, integracion, notificacion, etc
|
||||
descripcion String?
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("configuracion")
|
||||
}
|
||||
72
scripts/backup-db.sh
Normal file
72
scripts/backup-db.sh
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MSP Monitor Dashboard - Database Backup Script
|
||||
# Crea un backup de la base de datos PostgreSQL
|
||||
|
||||
set -e
|
||||
|
||||
# Configuracion
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="msp_monitor_${DATE}.sql.gz"
|
||||
|
||||
# Cargar variables de entorno
|
||||
if [ -f .env ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
# Valores por defecto
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${POSTGRES_USER:-mspmonitor}"
|
||||
DB_NAME="${POSTGRES_DB:-msp_monitor}"
|
||||
DB_PASSWORD="${POSTGRES_PASSWORD:-changeme}"
|
||||
|
||||
echo "======================================"
|
||||
echo " MSP Monitor - Database Backup"
|
||||
echo "======================================"
|
||||
echo "Fecha: $(date)"
|
||||
echo "Base de datos: $DB_NAME"
|
||||
echo ""
|
||||
|
||||
# Crear directorio de backups si no existe
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Crear backup
|
||||
echo "Creando backup..."
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||
--no-owner --no-privileges --format=custom | gzip > "$BACKUP_DIR/$BACKUP_FILE"
|
||||
|
||||
unset PGPASSWORD
|
||||
|
||||
# Verificar que el backup se creo correctamente
|
||||
if [ -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
|
||||
echo "[✓] Backup creado: $BACKUP_DIR/$BACKUP_FILE ($SIZE)"
|
||||
else
|
||||
echo "[✗] Error creando backup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Eliminar backups antiguos
|
||||
echo ""
|
||||
echo "Limpiando backups antiguos (mas de $RETENTION_DAYS dias)..."
|
||||
find "$BACKUP_DIR" -name "msp_monitor_*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete
|
||||
REMAINING=$(ls -1 "$BACKUP_DIR"/msp_monitor_*.sql.gz 2>/dev/null | wc -l)
|
||||
echo "[✓] Backups restantes: $REMAINING"
|
||||
|
||||
# Opcional: Subir a S3 si esta configurado
|
||||
if [ -n "$S3_BUCKET" ] && command -v aws &> /dev/null; then
|
||||
echo ""
|
||||
echo "Subiendo backup a S3..."
|
||||
aws s3 cp "$BACKUP_DIR/$BACKUP_FILE" "s3://$S3_BUCKET/backups/$BACKUP_FILE"
|
||||
echo "[✓] Backup subido a s3://$S3_BUCKET/backups/$BACKUP_FILE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo " Backup completado"
|
||||
echo "======================================"
|
||||
103
scripts/restore-db.sh
Normal file
103
scripts/restore-db.sh
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MSP Monitor Dashboard - Database Restore Script
|
||||
# Restaura un backup de la base de datos PostgreSQL
|
||||
|
||||
set -e
|
||||
|
||||
# Verificar argumentos
|
||||
if [ -z "$1" ]; then
|
||||
echo "Uso: $0 <archivo_backup>"
|
||||
echo ""
|
||||
echo "Ejemplo: $0 /backups/msp_monitor_20240115_120000.sql.gz"
|
||||
echo ""
|
||||
echo "Backups disponibles:"
|
||||
ls -la ${BACKUP_DIR:-/backups}/msp_monitor_*.sql.gz 2>/dev/null || echo "No se encontraron backups"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
|
||||
# Verificar que el archivo existe
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "[✗] Archivo no encontrado: $BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cargar variables de entorno
|
||||
if [ -f .env ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
# Valores por defecto
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${POSTGRES_USER:-mspmonitor}"
|
||||
DB_NAME="${POSTGRES_DB:-msp_monitor}"
|
||||
DB_PASSWORD="${POSTGRES_PASSWORD:-changeme}"
|
||||
|
||||
echo "======================================"
|
||||
echo " MSP Monitor - Database Restore"
|
||||
echo "======================================"
|
||||
echo "Fecha: $(date)"
|
||||
echo "Archivo: $BACKUP_FILE"
|
||||
echo "Base de datos: $DB_NAME"
|
||||
echo ""
|
||||
|
||||
# Confirmar restauracion
|
||||
echo "ADVERTENCIA: Esta operacion eliminara todos los datos actuales."
|
||||
read -p "¿Desea continuar? (si/no): " CONFIRM
|
||||
|
||||
if [ "$CONFIRM" != "si" ]; then
|
||||
echo "Operacion cancelada"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
# Crear backup de seguridad antes de restaurar
|
||||
echo ""
|
||||
echo "Creando backup de seguridad antes de restaurar..."
|
||||
SAFETY_BACKUP="/tmp/msp_monitor_pre_restore_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||
--no-owner --no-privileges --format=custom | gzip > "$SAFETY_BACKUP"
|
||||
echo "[✓] Backup de seguridad: $SAFETY_BACKUP"
|
||||
|
||||
# Terminar conexiones activas
|
||||
echo ""
|
||||
echo "Terminando conexiones activas..."
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c \
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" \
|
||||
> /dev/null 2>&1 || true
|
||||
|
||||
# Eliminar y recrear base de datos
|
||||
echo ""
|
||||
echo "Recreando base de datos..."
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME;"
|
||||
echo "[✓] Base de datos recreada"
|
||||
|
||||
# Restaurar backup
|
||||
echo ""
|
||||
echo "Restaurando backup..."
|
||||
if [[ "$BACKUP_FILE" == *.gz ]]; then
|
||||
gunzip -c "$BACKUP_FILE" | pg_restore -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges
|
||||
else
|
||||
pg_restore -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges "$BACKUP_FILE"
|
||||
fi
|
||||
echo "[✓] Backup restaurado"
|
||||
|
||||
unset PGPASSWORD
|
||||
|
||||
# Aplicar migraciones pendientes
|
||||
echo ""
|
||||
echo "Verificando migraciones..."
|
||||
npx prisma db push --skip-generate 2>/dev/null || echo "[!] No se pudieron aplicar migraciones automaticamente"
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo " Restauracion completada"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "El backup de seguridad esta en: $SAFETY_BACKUP"
|
||||
echo "Elimine manualmente cuando ya no lo necesite."
|
||||
169
scripts/setup.sh
Normal file
169
scripts/setup.sh
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MSP Monitor Dashboard - Setup Script
|
||||
# Este script configura el entorno de desarrollo/produccion
|
||||
|
||||
set -e
|
||||
|
||||
echo "======================================"
|
||||
echo " MSP Monitor Dashboard - Setup"
|
||||
echo "======================================"
|
||||
|
||||
# Colores para output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Funcion para imprimir mensajes
|
||||
print_status() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[!]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[✗]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar si se ejecuta como root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
print_warning "Este script debe ejecutarse como root para algunas operaciones"
|
||||
fi
|
||||
|
||||
# Verificar dependencias
|
||||
echo ""
|
||||
echo "Verificando dependencias..."
|
||||
|
||||
check_command() {
|
||||
if command -v $1 &> /dev/null; then
|
||||
print_status "$1 instalado"
|
||||
return 0
|
||||
else
|
||||
print_error "$1 no encontrado"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
MISSING_DEPS=0
|
||||
check_command docker || MISSING_DEPS=1
|
||||
check_command docker-compose || check_command "docker compose" || MISSING_DEPS=1
|
||||
check_command node || MISSING_DEPS=1
|
||||
check_command npm || MISSING_DEPS=1
|
||||
|
||||
if [ $MISSING_DEPS -eq 1 ]; then
|
||||
print_error "Faltan dependencias. Instale las herramientas faltantes antes de continuar."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Crear archivo .env si no existe
|
||||
if [ ! -f .env ]; then
|
||||
echo ""
|
||||
echo "Creando archivo .env..."
|
||||
cp .env.example .env
|
||||
|
||||
# Generar JWT_SECRET aleatorio
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
sed -i "s|JWT_SECRET=.*|JWT_SECRET=\"$JWT_SECRET\"|" .env
|
||||
|
||||
print_status "Archivo .env creado"
|
||||
print_warning "Edite el archivo .env con sus configuraciones antes de continuar"
|
||||
else
|
||||
print_status "Archivo .env ya existe"
|
||||
fi
|
||||
|
||||
# Instalar dependencias de Node.js
|
||||
echo ""
|
||||
echo "Instalando dependencias de Node.js..."
|
||||
npm install
|
||||
print_status "Dependencias instaladas"
|
||||
|
||||
# Generar cliente de Prisma
|
||||
echo ""
|
||||
echo "Generando cliente de Prisma..."
|
||||
npx prisma generate
|
||||
print_status "Cliente de Prisma generado"
|
||||
|
||||
# Preguntar si iniciar en modo desarrollo o produccion
|
||||
echo ""
|
||||
echo "Seleccione el modo de ejecucion:"
|
||||
echo "1) Desarrollo (npm run dev)"
|
||||
echo "2) Produccion (Docker)"
|
||||
echo "3) Solo configurar, no iniciar"
|
||||
read -p "Opcion [1-3]: " MODE
|
||||
|
||||
case $MODE in
|
||||
1)
|
||||
echo ""
|
||||
echo "Iniciando servicios de desarrollo..."
|
||||
|
||||
# Iniciar PostgreSQL y Redis con Docker
|
||||
docker-compose -f docker/docker-compose.yml up -d postgres redis
|
||||
|
||||
# Esperar a que los servicios esten listos
|
||||
echo "Esperando a que los servicios esten listos..."
|
||||
sleep 5
|
||||
|
||||
# Aplicar migraciones
|
||||
echo ""
|
||||
echo "Aplicando migraciones de base de datos..."
|
||||
npx prisma db push
|
||||
print_status "Migraciones aplicadas"
|
||||
|
||||
# Iniciar aplicacion
|
||||
echo ""
|
||||
print_status "Iniciando servidor de desarrollo..."
|
||||
npm run dev
|
||||
;;
|
||||
|
||||
2)
|
||||
echo ""
|
||||
echo "Construyendo e iniciando contenedores de produccion..."
|
||||
|
||||
# Construir imagenes
|
||||
docker-compose -f docker/docker-compose.yml build
|
||||
|
||||
# Iniciar servicios
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Esperar a que los servicios esten listos
|
||||
echo "Esperando a que los servicios esten listos..."
|
||||
sleep 10
|
||||
|
||||
# Aplicar migraciones
|
||||
echo ""
|
||||
echo "Aplicando migraciones de base de datos..."
|
||||
docker-compose -f docker/docker-compose.yml exec dashboard npx prisma db push
|
||||
print_status "Migraciones aplicadas"
|
||||
|
||||
print_status "Servicios iniciados"
|
||||
echo ""
|
||||
echo "Dashboard disponible en: http://localhost:3000"
|
||||
echo ""
|
||||
echo "Comandos utiles:"
|
||||
echo " docker-compose -f docker/docker-compose.yml logs -f # Ver logs"
|
||||
echo " docker-compose -f docker/docker-compose.yml down # Detener servicios"
|
||||
echo " docker-compose -f docker/docker-compose.yml restart # Reiniciar servicios"
|
||||
;;
|
||||
|
||||
3)
|
||||
print_status "Configuracion completada"
|
||||
echo ""
|
||||
echo "Proximos pasos:"
|
||||
echo "1. Edite el archivo .env con sus configuraciones"
|
||||
echo "2. Ejecute 'npm run dev' para desarrollo"
|
||||
echo "3. O ejecute 'docker-compose -f docker/docker-compose.yml up -d' para produccion"
|
||||
;;
|
||||
|
||||
*)
|
||||
print_error "Opcion no valida"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo " Setup completado"
|
||||
echo "======================================"
|
||||
40
src/app/(dashboard)/layout.tsx
Normal file
40
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [alertasActivas, setAlertasActivas] = useState(0)
|
||||
const [user, setUser] = useState({
|
||||
nombre: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
rol: 'SUPER_ADMIN',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Cargar alertas activas desde API
|
||||
// TODO: Cargar usuario desde sesion
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
// TODO: Implementar logout
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-dark-500">
|
||||
<Sidebar alertasActivas={alertasActivas} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header user={user} onLogout={handleLogout} />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
src/app/(dashboard)/page.tsx
Normal file
253
src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
|
||||
import KPICards from '@/components/dashboard/KPICards'
|
||||
import DeviceGrid from '@/components/dashboard/DeviceGrid'
|
||||
import AlertsFeed from '@/components/dashboard/AlertsFeed'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Mock data - en produccion vendria de la API
|
||||
const mockStats = {
|
||||
totalDispositivos: 127,
|
||||
dispositivosOnline: 98,
|
||||
dispositivosOffline: 24,
|
||||
dispositivosAlerta: 5,
|
||||
alertasActivas: 8,
|
||||
alertasCriticas: 2,
|
||||
sesionesActivas: 3,
|
||||
}
|
||||
|
||||
const mockDevices = [
|
||||
{
|
||||
id: '1',
|
||||
nombre: 'SRV-PRINCIPAL',
|
||||
tipo: 'SERVIDOR',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.10',
|
||||
sistemaOperativo: 'Windows Server 2022',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: 45,
|
||||
ramUsage: 72,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nombre: 'PC-ADMIN-01',
|
||||
tipo: 'PC',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.101',
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: 23,
|
||||
ramUsage: 56,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nombre: 'LAPTOP-VENTAS',
|
||||
tipo: 'LAPTOP',
|
||||
estado: 'ALERTA',
|
||||
ip: '192.168.1.105',
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
lastSeen: new Date(Date.now() - 1000 * 60 * 5),
|
||||
cpuUsage: 95,
|
||||
ramUsage: 88,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nombre: 'ROUTER-PRINCIPAL',
|
||||
tipo: 'ROUTER',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.1',
|
||||
sistemaOperativo: 'RouterOS 7.12',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nombre: 'SW-CORE-01',
|
||||
tipo: 'SWITCH',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.2',
|
||||
sistemaOperativo: 'Cisco IOS',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
nombre: 'CELULAR-GERENTE',
|
||||
tipo: 'CELULAR',
|
||||
estado: 'ONLINE',
|
||||
ip: null,
|
||||
sistemaOperativo: 'Android 14',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
nombre: 'SRV-BACKUP',
|
||||
tipo: 'SERVIDOR',
|
||||
estado: 'OFFLINE',
|
||||
ip: '192.168.1.11',
|
||||
sistemaOperativo: 'Ubuntu 22.04',
|
||||
lastSeen: new Date(Date.now() - 1000 * 60 * 60 * 2),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
nombre: 'AP-OFICINA-01',
|
||||
tipo: 'AP',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.50',
|
||||
sistemaOperativo: 'UniFi AP',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
]
|
||||
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
severidad: 'CRITICAL' as const,
|
||||
estado: 'ACTIVA' as const,
|
||||
titulo: 'Servidor de backup offline',
|
||||
mensaje: 'El servidor SRV-BACKUP no responde desde hace 2 horas',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 120),
|
||||
dispositivo: { nombre: 'SRV-BACKUP' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
severidad: 'WARNING' as const,
|
||||
estado: 'ACTIVA' as const,
|
||||
titulo: 'CPU alta',
|
||||
mensaje: 'Uso de CPU al 95% en LAPTOP-VENTAS',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 15),
|
||||
dispositivo: { nombre: 'LAPTOP-VENTAS' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
severidad: 'INFO' as const,
|
||||
estado: 'RECONOCIDA' as const,
|
||||
titulo: 'Actualizacion disponible',
|
||||
mensaje: 'Windows Update pendiente en PC-ADMIN-01',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60),
|
||||
dispositivo: { nombre: 'PC-ADMIN-01' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
]
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [stats, setStats] = useState(mockStats)
|
||||
const [devices, setDevices] = useState(mockDevices)
|
||||
const [alerts, setAlerts] = useState(mockAlerts)
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
// TODO: Recargar datos de la API
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const handleDeviceAction = (deviceId: string, action: string) => {
|
||||
console.log(`Action ${action} on device ${deviceId}`)
|
||||
// TODO: Implementar acciones
|
||||
}
|
||||
|
||||
const handleAcknowledgeAlert = (alertId: string) => {
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RECONOCIDA' as const } : a))
|
||||
)
|
||||
// TODO: Llamar API
|
||||
}
|
||||
|
||||
const handleResolveAlert = (alertId: string) => {
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RESUELTA' as const } : a))
|
||||
)
|
||||
// TODO: Llamar API
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-gray-500">Vision general del sistema</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="btn btn-secondary"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<KPICards stats={stats} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Devices */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Dispositivos</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
Filtrar
|
||||
</button>
|
||||
<div className="flex border border-dark-100 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
viewMode === 'grid' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
viewMode === 'list' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeviceGrid
|
||||
devices={devices}
|
||||
viewMode={viewMode}
|
||||
onAction={handleDeviceAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
<div>
|
||||
<AlertsFeed
|
||||
alerts={alerts}
|
||||
onAcknowledge={handleAcknowledgeAlert}
|
||||
onResolve={handleResolveAlert}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
249
src/app/globals.css
Normal file
249
src/app/globals.css
Normal file
@@ -0,0 +1,249 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #0d1321;
|
||||
--foreground: #f1f5f9;
|
||||
--primary: #06b6d4;
|
||||
--primary-hover: #0891b2;
|
||||
--card: #1a2234;
|
||||
--card-hover: #1e293b;
|
||||
--border: #334155;
|
||||
--muted: #64748b;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--primary) var(--card);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--card);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-dark-500 text-gray-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-dark-200 border border-dark-100 rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply px-4 py-3 border-b border-dark-100;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-400 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-dark-100 hover:bg-dark-300 text-gray-200 border border-dark-100 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-danger hover:bg-red-600 text-white focus:ring-red-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent hover:bg-dark-100 text-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-sm;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-lg;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 bg-dark-300 border border-dark-100 rounded-lg text-gray-100 placeholder-gray-500 focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 transition-colors;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-danger focus:border-danger focus:ring-danger;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-300 mb-1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success/20 text-success;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning/20 text-warning;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-danger/20 text-danger;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply bg-info/20 text-info;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-500/20 text-gray-400;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply w-full text-left;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
@apply bg-dark-300 text-gray-400 text-sm uppercase;
|
||||
}
|
||||
|
||||
.table th {
|
||||
@apply px-4 py-3 font-medium;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
@apply border-b border-dark-100 hover:bg-dark-300/50 transition-colors;
|
||||
}
|
||||
|
||||
.table td {
|
||||
@apply px-4 py-3;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-100 transition-colors;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
@apply bg-primary-900/50 text-primary-400 border-l-2 border-primary-500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-dot-online {
|
||||
@apply bg-success animate-pulse;
|
||||
}
|
||||
|
||||
.status-dot-offline {
|
||||
@apply bg-gray-500;
|
||||
}
|
||||
|
||||
.status-dot-alert {
|
||||
@apply bg-danger animate-pulse;
|
||||
}
|
||||
|
||||
.status-dot-maintenance {
|
||||
@apply bg-warning;
|
||||
}
|
||||
|
||||
.glow-border {
|
||||
@apply border border-primary-500/50 shadow-glow;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
@apply bg-dark-200 border border-dark-100 rounded-lg p-2 shadow-lg text-sm;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
@apply absolute right-0 mt-2 w-48 bg-dark-200 border border-dark-100 rounded-lg shadow-lg py-1 z-50;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply block px-4 py-2 text-sm text-gray-300 hover:bg-dark-100 hover:text-white transition-colors;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black/60 backdrop-blur-sm z-40;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply bg-dark-200 border border-dark-100 rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex items-center justify-between px-6 py-4 border-b border-dark-100;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-100;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-dark-100 animate-pulse rounded;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply absolute z-50 px-2 py-1 text-xs bg-dark-100 border border-dark-300 rounded shadow-lg whitespace-nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.glow-sm {
|
||||
box-shadow: 0 0 10px rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
|
||||
.glow-md {
|
||||
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.glow-lg {
|
||||
box-shadow: 0 0 40px rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary-400 to-primary-600;
|
||||
}
|
||||
|
||||
.gradient-border {
|
||||
border-image: linear-gradient(to right, #06b6d4, #0891b2) 1;
|
||||
}
|
||||
}
|
||||
27
src/app/layout.tsx
Normal file
27
src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MSP Monitor - Dashboard',
|
||||
description: 'Dashboard de monitoreo para MSP - MeshCentral, LibreNMS, Headwind MDM',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className="dark">
|
||||
<body className={`${inter.className} dark`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
162
src/components/dashboard/AlertsFeed.tsx
Normal file
162
src/components/dashboard/AlertsFeed.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
interface Alert {
|
||||
id: string
|
||||
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
|
||||
titulo: string
|
||||
mensaje: string
|
||||
createdAt: Date
|
||||
dispositivo?: { nombre: string } | null
|
||||
cliente: { nombre: string }
|
||||
}
|
||||
|
||||
interface AlertsFeedProps {
|
||||
alerts: Alert[]
|
||||
onAcknowledge?: (alertId: string) => void
|
||||
onResolve?: (alertId: string) => void
|
||||
maxItems?: number
|
||||
}
|
||||
|
||||
export default function AlertsFeed({
|
||||
alerts,
|
||||
onAcknowledge,
|
||||
onResolve,
|
||||
maxItems = 10,
|
||||
}: AlertsFeedProps) {
|
||||
const displayAlerts = alerts.slice(0, maxItems)
|
||||
|
||||
if (displayAlerts.length === 0) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<CheckCircle className="w-12 h-12 text-success mx-auto mb-3" />
|
||||
<p className="text-gray-400">No hay alertas activas</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="font-medium">Alertas Recientes</h3>
|
||||
<a href="/alertas" className="text-sm text-primary-500 hover:underline">
|
||||
Ver todas
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-dark-100">
|
||||
{displayAlerts.map((alert) => (
|
||||
<AlertItem
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onAcknowledge={onAcknowledge}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertItem({
|
||||
alert,
|
||||
onAcknowledge,
|
||||
onResolve,
|
||||
}: {
|
||||
alert: Alert
|
||||
onAcknowledge?: (alertId: string) => void
|
||||
onResolve?: (alertId: string) => void
|
||||
}) {
|
||||
const severityConfig = {
|
||||
CRITICAL: {
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
color: 'text-danger',
|
||||
bgColor: 'bg-danger/20',
|
||||
borderColor: 'border-l-danger',
|
||||
},
|
||||
WARNING: {
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
color: 'text-warning',
|
||||
bgColor: 'bg-warning/20',
|
||||
borderColor: 'border-l-warning',
|
||||
},
|
||||
INFO: {
|
||||
icon: <Info className="w-5 h-5" />,
|
||||
color: 'text-info',
|
||||
bgColor: 'bg-info/20',
|
||||
borderColor: 'border-l-info',
|
||||
},
|
||||
}
|
||||
|
||||
const config = severityConfig[alert.severidad]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border-l-4 hover:bg-dark-300/30 transition-colors',
|
||||
config.borderColor,
|
||||
alert.severidad === 'CRITICAL' && 'animate-pulse-slow'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn('p-2 rounded-lg', config.bgColor)}>
|
||||
<span className={config.color}>{config.icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm">{alert.titulo}</h4>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{alert.mensaje}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'badge shrink-0',
|
||||
alert.estado === 'ACTIVA' && 'badge-danger',
|
||||
alert.estado === 'RECONOCIDA' && 'badge-warning',
|
||||
alert.estado === 'RESUELTA' && 'badge-success'
|
||||
)}
|
||||
>
|
||||
{alert.estado}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(alert.createdAt)}
|
||||
</div>
|
||||
{alert.dispositivo && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{alert.dispositivo.nombre}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-600">
|
||||
{alert.cliente.nombre}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{alert.estado === 'ACTIVA' && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => onAcknowledge?.(alert.id)}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Reconocer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onResolve?.(alert.id)}
|
||||
className="btn btn-ghost btn-sm text-success"
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
341
src/components/dashboard/DeviceGrid.tsx
Normal file
341
src/components/dashboard/DeviceGrid.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Monitor,
|
||||
Laptop,
|
||||
Server,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Router,
|
||||
Network,
|
||||
Shield,
|
||||
Wifi,
|
||||
Printer,
|
||||
HelpCircle,
|
||||
MoreVertical,
|
||||
ExternalLink,
|
||||
Power,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor } from '@/lib/utils'
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
estado: string
|
||||
ip?: string | null
|
||||
sistemaOperativo?: string | null
|
||||
lastSeen?: Date | null
|
||||
cpuUsage?: number | null
|
||||
ramUsage?: number | null
|
||||
cliente?: { nombre: string }
|
||||
}
|
||||
|
||||
interface DeviceGridProps {
|
||||
devices: Device[]
|
||||
viewMode?: 'grid' | 'list'
|
||||
onAction?: (deviceId: string, action: string) => void
|
||||
}
|
||||
|
||||
const deviceIcons: Record<string, React.ReactNode> = {
|
||||
PC: <Monitor className="w-8 h-8" />,
|
||||
LAPTOP: <Laptop className="w-8 h-8" />,
|
||||
SERVIDOR: <Server className="w-8 h-8" />,
|
||||
CELULAR: <Smartphone className="w-8 h-8" />,
|
||||
TABLET: <Tablet className="w-8 h-8" />,
|
||||
ROUTER: <Router className="w-8 h-8" />,
|
||||
SWITCH: <Network className="w-8 h-8" />,
|
||||
FIREWALL: <Shield className="w-8 h-8" />,
|
||||
AP: <Wifi className="w-8 h-8" />,
|
||||
IMPRESORA: <Printer className="w-8 h-8" />,
|
||||
OTRO: <HelpCircle className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
export default function DeviceGrid({ devices, viewMode = 'grid', onAction }: DeviceGridProps) {
|
||||
if (viewMode === 'list') {
|
||||
return <DeviceList devices={devices} onAction={onAction} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{devices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceCard({
|
||||
device,
|
||||
onAction,
|
||||
}: {
|
||||
device: Device
|
||||
onAction?: (deviceId: string, action: string) => void
|
||||
}) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const getDeviceUrl = () => {
|
||||
const type = device.tipo
|
||||
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/equipos/${device.id}`
|
||||
if (['CELULAR', 'TABLET'].includes(type)) return `/celulares/${device.id}`
|
||||
return `/red/${device.id}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card p-4 transition-all hover:border-primary-500/50 relative group',
|
||||
device.estado === 'ALERTA' && 'border-danger/50'
|
||||
)}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'status-dot',
|
||||
device.estado === 'ONLINE' && 'status-dot-online',
|
||||
device.estado === 'OFFLINE' && 'status-dot-offline',
|
||||
device.estado === 'ALERTA' && 'status-dot-alert',
|
||||
device.estado === 'MANTENIMIENTO' && 'status-dot-maintenance'
|
||||
)}
|
||||
/>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 rounded hover:bg-dark-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div className="dropdown right-0 z-50">
|
||||
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'desktop')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Escritorio remoto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'terminal')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'files')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Archivos
|
||||
</button>
|
||||
<div className="h-px bg-dark-100 my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'restart')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2 text-warning"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon and name */}
|
||||
<Link href={getDeviceUrl()} className="block">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className={cn('p-3 rounded-lg', getStatusBgColor(device.estado))}>
|
||||
<span className={getStatusColor(device.estado)}>
|
||||
{deviceIcons[device.tipo] || deviceIcons.OTRO}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{device.nombre}</h3>
|
||||
<p className="text-xs text-gray-500">{device.tipo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 text-sm">
|
||||
{device.ip && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">IP</span>
|
||||
<span className="font-mono text-gray-300">{device.ip}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.sistemaOperativo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">OS</span>
|
||||
<span className="text-gray-300 truncate ml-2">{device.sistemaOperativo}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.lastSeen && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Visto</span>
|
||||
<span className="text-gray-400">{formatRelativeTime(device.lastSeen)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics bar */}
|
||||
{device.estado === 'ONLINE' && (device.cpuUsage !== null || device.ramUsage !== null) && (
|
||||
<div className="mt-3 pt-3 border-t border-dark-100 grid grid-cols-2 gap-2">
|
||||
{device.cpuUsage !== null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-500">CPU</span>
|
||||
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.cpuUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-dark-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
device.cpuUsage > 80 ? 'bg-danger' : device.cpuUsage > 60 ? 'bg-warning' : 'bg-success'
|
||||
)}
|
||||
style={{ width: `${device.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{device.ramUsage !== null && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-500">RAM</span>
|
||||
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.ramUsage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-dark-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
device.ramUsage > 80 ? 'bg-danger' : device.ramUsage > 60 ? 'bg-warning' : 'bg-success'
|
||||
)}
|
||||
style={{ width: `${device.ramUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceList({
|
||||
devices,
|
||||
onAction,
|
||||
}: {
|
||||
devices: Device[]
|
||||
onAction?: (deviceId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dispositivo</th>
|
||||
<th>Tipo</th>
|
||||
<th>IP</th>
|
||||
<th>Estado</th>
|
||||
<th>CPU</th>
|
||||
<th>RAM</th>
|
||||
<th>Ultimo contacto</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={getStatusColor(device.estado)}>
|
||||
{deviceIcons[device.tipo] || deviceIcons.OTRO}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-medium">{device.nombre}</div>
|
||||
{device.cliente && (
|
||||
<div className="text-xs text-gray-500">{device.cliente.nombre}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-gray-400">{device.tipo}</td>
|
||||
<td className="font-mono text-gray-400">{device.ip || '-'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={cn(
|
||||
'badge',
|
||||
device.estado === 'ONLINE' && 'badge-success',
|
||||
device.estado === 'OFFLINE' && 'badge-gray',
|
||||
device.estado === 'ALERTA' && 'badge-danger',
|
||||
device.estado === 'MANTENIMIENTO' && 'badge-warning'
|
||||
)}
|
||||
>
|
||||
{device.estado}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{device.cpuUsage !== null ? (
|
||||
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.cpuUsage)}%
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{device.ramUsage !== null ? (
|
||||
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||
{Math.round(device.ramUsage)}%
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
{device.lastSeen ? formatRelativeTime(device.lastSeen) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/equipos/${device.id}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Ver
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
src/components/dashboard/KPICards.tsx
Normal file
92
src/components/dashboard/KPICards.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Monitor, Smartphone, Network, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KPICardsProps {
|
||||
stats: {
|
||||
totalDispositivos: number
|
||||
dispositivosOnline: number
|
||||
dispositivosOffline: number
|
||||
dispositivosAlerta: number
|
||||
alertasActivas: number
|
||||
alertasCriticas: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function KPICards({ stats }: KPICardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Dispositivos',
|
||||
value: stats.totalDispositivos,
|
||||
icon: <Monitor className="w-6 h-6" />,
|
||||
color: 'text-primary-400',
|
||||
bgColor: 'bg-primary-900/30',
|
||||
},
|
||||
{
|
||||
title: 'En Linea',
|
||||
value: stats.dispositivosOnline,
|
||||
icon: <CheckCircle className="w-6 h-6" />,
|
||||
color: 'text-success',
|
||||
bgColor: 'bg-success/20',
|
||||
percentage: stats.totalDispositivos > 0
|
||||
? Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)
|
||||
: 0,
|
||||
},
|
||||
{
|
||||
title: 'Fuera de Linea',
|
||||
value: stats.dispositivosOffline,
|
||||
icon: <XCircle className="w-6 h-6" />,
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-500/20',
|
||||
},
|
||||
{
|
||||
title: 'Con Alertas',
|
||||
value: stats.dispositivosAlerta,
|
||||
icon: <AlertTriangle className="w-6 h-6" />,
|
||||
color: 'text-warning',
|
||||
bgColor: 'bg-warning/20',
|
||||
},
|
||||
{
|
||||
title: 'Alertas Activas',
|
||||
value: stats.alertasActivas,
|
||||
icon: <AlertTriangle className="w-6 h-6" />,
|
||||
color: 'text-danger',
|
||||
bgColor: 'bg-danger/20',
|
||||
highlight: stats.alertasCriticas > 0,
|
||||
subtitle: stats.alertasCriticas > 0
|
||||
? `${stats.alertasCriticas} criticas`
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'card p-4 transition-all hover:scale-[1.02]',
|
||||
card.highlight && 'border-danger glow-border animate-pulse'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{card.title}</p>
|
||||
<p className="text-3xl font-bold mt-1">{card.value}</p>
|
||||
{card.subtitle && (
|
||||
<p className="text-xs text-danger mt-1">{card.subtitle}</p>
|
||||
)}
|
||||
{card.percentage !== undefined && (
|
||||
<p className="text-xs text-gray-500 mt-1">{card.percentage}% del total</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-3 rounded-lg', card.bgColor)}>
|
||||
<span className={card.color}>{card.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
src/components/layout/ClientSelector.tsx
Normal file
135
src/components/layout/ClientSelector.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Building2, ChevronDown, Check, Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Client {
|
||||
id: string
|
||||
nombre: string
|
||||
codigo: string
|
||||
}
|
||||
|
||||
interface ClientSelectorProps {
|
||||
clients?: Client[]
|
||||
selectedId?: string | null
|
||||
onChange?: (clientId: string | null) => void
|
||||
showAll?: boolean
|
||||
}
|
||||
|
||||
export default function ClientSelector({
|
||||
clients = [],
|
||||
selectedId = null,
|
||||
onChange,
|
||||
showAll = true,
|
||||
}: ClientSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const selectedClient = selectedId
|
||||
? clients.find((c) => c.id === selectedId)
|
||||
: null
|
||||
|
||||
const filteredClients = clients.filter(
|
||||
(c) =>
|
||||
c.nombre.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.codigo.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const handleSelect = (id: string | null) => {
|
||||
onChange?.(id)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-300 border border-dark-100 rounded-lg hover:border-primary-500 transition-colors min-w-[200px]"
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-gray-500" />
|
||||
<span className="flex-1 text-left text-sm">
|
||||
{selectedClient ? selectedClient.nombre : 'Todos los clientes'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-gray-500 transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-0 mt-2 w-72 bg-dark-200 border border-dark-100 rounded-lg shadow-lg z-50">
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-dark-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar cliente..."
|
||||
className="input py-1.5 pl-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => handleSelect(null)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-dark-100 transition-colors',
|
||||
!selectedId && 'bg-primary-900/50 text-primary-400'
|
||||
)}
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">Todos los clientes</span>
|
||||
{!selectedId && <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filteredClients.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-gray-500 text-sm">
|
||||
No se encontraron clientes
|
||||
</div>
|
||||
) : (
|
||||
filteredClients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
onClick={() => handleSelect(client.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-dark-100 transition-colors',
|
||||
selectedId === client.id && 'bg-primary-900/50 text-primary-400'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-100 flex items-center justify-center text-xs font-medium text-gray-400">
|
||||
{client.codigo.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{client.nombre}</div>
|
||||
<div className="text-xs text-gray-500">{client.codigo}</div>
|
||||
</div>
|
||||
{selectedId === client.id && <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
src/components/layout/Header.tsx
Normal file
173
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bell, Search, User, LogOut, Settings, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ClientSelector from './ClientSelector'
|
||||
|
||||
interface HeaderProps {
|
||||
user?: {
|
||||
nombre: string
|
||||
email: string
|
||||
avatar?: string
|
||||
rol: string
|
||||
}
|
||||
onLogout?: () => void
|
||||
}
|
||||
|
||||
export default function Header({ user, onLogout }: HeaderProps) {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between px-6">
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar dispositivos, clientes..."
|
||||
className="input pl-10 bg-dark-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Selector */}
|
||||
<ClientSelector />
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="relative p-2 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-danger rounded-full" />
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowNotifications(false)}
|
||||
/>
|
||||
<div className="dropdown w-80 right-0 z-50">
|
||||
<div className="px-4 py-3 border-b border-dark-100">
|
||||
<h3 className="font-medium">Notificaciones</h3>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<NotificationItem
|
||||
type="critical"
|
||||
title="Servidor principal offline"
|
||||
message="El servidor SRV-01 no responde"
|
||||
time="hace 5 min"
|
||||
/>
|
||||
<NotificationItem
|
||||
type="warning"
|
||||
title="CPU alta en PC-ADMIN"
|
||||
message="Uso de CPU al 95%"
|
||||
time="hace 15 min"
|
||||
/>
|
||||
<NotificationItem
|
||||
type="info"
|
||||
title="Backup completado"
|
||||
message="Backup diario finalizado"
|
||||
time="hace 1 hora"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-dark-100">
|
||||
<a href="/alertas" className="text-primary-500 text-sm hover:underline">
|
||||
Ver todas las alertas
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-dark-100 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
user?.nombre?.charAt(0).toUpperCase() || 'U'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<div className="text-sm font-medium text-gray-200">{user?.nombre || 'Usuario'}</div>
|
||||
<div className="text-xs text-gray-500">{user?.rol || 'Rol'}</div>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
<div className="dropdown z-50">
|
||||
<div className="px-4 py-3 border-b border-dark-100">
|
||||
<div className="text-sm font-medium">{user?.nombre}</div>
|
||||
<div className="text-xs text-gray-500">{user?.email}</div>
|
||||
</div>
|
||||
<a href="/perfil" className="dropdown-item flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Mi perfil
|
||||
</a>
|
||||
<a href="/configuracion" className="dropdown-item flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configuracion
|
||||
</a>
|
||||
<div className="h-px bg-dark-100 my-1" />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="dropdown-item flex items-center gap-2 w-full text-left text-danger"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Cerrar sesion
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
type: 'critical' | 'warning' | 'info'
|
||||
title: string
|
||||
message: string
|
||||
time: string
|
||||
}
|
||||
|
||||
function NotificationItem({ type, title, message, time }: NotificationItemProps) {
|
||||
const colors = {
|
||||
critical: 'bg-danger/20 border-danger',
|
||||
warning: 'bg-warning/20 border-warning',
|
||||
info: 'bg-info/20 border-info',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-3 border-l-4 hover:bg-dark-100 cursor-pointer transition-colors',
|
||||
colors[type]
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-sm">{title}</div>
|
||||
<div className="text-xs text-gray-400">{message}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{time}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
src/components/layout/Sidebar.tsx
Normal file
182
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Network,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
Building2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
badge?: number
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/',
|
||||
icon: <LayoutDashboard className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Equipos',
|
||||
href: '/equipos',
|
||||
icon: <Monitor className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Celulares',
|
||||
href: '/celulares',
|
||||
icon: <Smartphone className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Red',
|
||||
href: '/red',
|
||||
icon: <Network className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Alertas',
|
||||
href: '/alertas',
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Reportes',
|
||||
href: '/reportes',
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
{
|
||||
label: 'Clientes',
|
||||
href: '/clientes',
|
||||
icon: <Building2 className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Usuarios',
|
||||
href: '/usuarios',
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Configuracion',
|
||||
href: '/configuracion',
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarProps {
|
||||
alertasActivas?: number
|
||||
}
|
||||
|
||||
export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const items = navItems.map((item) => ({
|
||||
...item,
|
||||
badge: item.href === '/alertas' ? alertasActivas : undefined,
|
||||
}))
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'h-screen bg-dark-400 border-r border-dark-100 flex flex-col transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-100">
|
||||
{!collapsed && (
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Activity className="w-8 h-8 text-primary-500" />
|
||||
<span className="font-bold text-lg gradient-text">MSP Monitor</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-1.5 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'sidebar-link',
|
||||
isActive(item.href) && 'active',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<span className="badge badge-danger">{item.badge}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{collapsed && item.badge !== undefined && item.badge > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger rounded-full text-xs flex items-center justify-center">
|
||||
{item.badge > 9 ? '9+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Separador */}
|
||||
<div className="h-px bg-dark-100 my-4" />
|
||||
|
||||
{/* Admin items */}
|
||||
{adminItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'sidebar-link',
|
||||
isActive(item.href) && 'active',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span className="flex-1">{item.label}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
{!collapsed && (
|
||||
<div className="p-4 border-t border-dark-100">
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
MSP Monitor v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
110
src/lib/auth.ts
Normal file
110
src/lib/auth.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { cookies } from 'next/headers'
|
||||
import { SessionUser } from '@/types'
|
||||
import prisma from './prisma'
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'development-secret-key-change-in-production')
|
||||
const COOKIE_NAME = 'msp-session'
|
||||
|
||||
export async function createSession(user: SessionUser): Promise<string> {
|
||||
const token = await new SignJWT({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
rol: user.rol,
|
||||
clienteId: user.clienteId,
|
||||
meshcentralUser: user.meshcentralUser,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('24h')
|
||||
.sign(JWT_SECRET)
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function verifySession(token: string): Promise<SessionUser | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||
return payload as unknown as SessionUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionUser | null> {
|
||||
const cookieStore = cookies()
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value
|
||||
|
||||
if (!token) return null
|
||||
return verifySession(token)
|
||||
}
|
||||
|
||||
export async function setSessionCookie(token: string): Promise<void> {
|
||||
const cookieStore = cookies()
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24, // 24 horas
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearSession(): Promise<void> {
|
||||
const cookieStore = cookies()
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function validateMeshCentralUser(username: string, token: string): Promise<SessionUser | null> {
|
||||
// Verificar con MeshCentral que el token es valido
|
||||
const meshUrl = process.env.MESHCENTRAL_URL
|
||||
if (!meshUrl) return null
|
||||
|
||||
try {
|
||||
const response = await fetch(`${meshUrl}/api/users`, {
|
||||
headers: {
|
||||
'x-meshauth': token,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
// Buscar o crear usuario en nuestra BD
|
||||
let usuario = await prisma.usuario.findUnique({
|
||||
where: { meshcentralUser: username },
|
||||
include: { cliente: true },
|
||||
})
|
||||
|
||||
if (!usuario) {
|
||||
// Crear usuario si no existe
|
||||
usuario = await prisma.usuario.create({
|
||||
data: {
|
||||
email: `${username}@meshcentral.local`,
|
||||
nombre: username,
|
||||
meshcentralUser: username,
|
||||
rol: 'TECNICO',
|
||||
},
|
||||
include: { cliente: true },
|
||||
})
|
||||
}
|
||||
|
||||
// Actualizar lastLogin
|
||||
await prisma.usuario.update({
|
||||
where: { id: usuario.id },
|
||||
data: { lastLogin: new Date() },
|
||||
})
|
||||
|
||||
return {
|
||||
id: usuario.id,
|
||||
email: usuario.email,
|
||||
nombre: usuario.nombre,
|
||||
rol: usuario.rol,
|
||||
clienteId: usuario.clienteId,
|
||||
meshcentralUser: usuario.meshcentralUser,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating MeshCentral user:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
15
src/lib/prisma.ts
Normal file
15
src/lib/prisma.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
export default prisma
|
||||
38
src/lib/redis.ts
Normal file
38
src/lib/redis.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Redis from 'ioredis'
|
||||
|
||||
const globalForRedis = globalThis as unknown as {
|
||||
redis: Redis | undefined
|
||||
}
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ??
|
||||
new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis
|
||||
|
||||
export default redis
|
||||
|
||||
// Funciones helper para cache
|
||||
export async function getCache<T>(key: string): Promise<T | null> {
|
||||
const data = await redis.get(key)
|
||||
if (!data) return null
|
||||
return JSON.parse(data) as T
|
||||
}
|
||||
|
||||
export async function setCache<T>(key: string, value: T, ttlSeconds: number = 300): Promise<void> {
|
||||
await redis.setex(key, ttlSeconds, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export async function deleteCache(key: string): Promise<void> {
|
||||
await redis.del(key)
|
||||
}
|
||||
|
||||
export async function invalidatePattern(pattern: string): Promise<void> {
|
||||
const keys = await redis.keys(pattern)
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys)
|
||||
}
|
||||
}
|
||||
123
src/lib/utils.ts
Normal file
123
src/lib/utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `hace ${days}d`
|
||||
if (hours > 0) return `hace ${hours}h`
|
||||
if (minutes > 0) return `hace ${minutes}m`
|
||||
return 'ahora'
|
||||
}
|
||||
|
||||
export function getStatusColor(status: string): string {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'ONLINE':
|
||||
return 'text-success'
|
||||
case 'OFFLINE':
|
||||
return 'text-gray-500'
|
||||
case 'ALERTA':
|
||||
return 'text-danger'
|
||||
case 'MANTENIMIENTO':
|
||||
return 'text-warning'
|
||||
default:
|
||||
return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusBgColor(status: string): string {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'ONLINE':
|
||||
return 'bg-success/20'
|
||||
case 'OFFLINE':
|
||||
return 'bg-gray-500/20'
|
||||
case 'ALERTA':
|
||||
return 'bg-danger/20'
|
||||
case 'MANTENIMIENTO':
|
||||
return 'bg-warning/20'
|
||||
default:
|
||||
return 'bg-gray-400/20'
|
||||
}
|
||||
}
|
||||
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity.toUpperCase()) {
|
||||
case 'CRITICAL':
|
||||
return 'text-danger'
|
||||
case 'WARNING':
|
||||
return 'text-warning'
|
||||
case 'INFO':
|
||||
return 'text-info'
|
||||
default:
|
||||
return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
export function getSeverityBgColor(severity: string): string {
|
||||
switch (severity.toUpperCase()) {
|
||||
case 'CRITICAL':
|
||||
return 'bg-danger/20'
|
||||
case 'WARNING':
|
||||
return 'bg-warning/20'
|
||||
case 'INFO':
|
||||
return 'bg-info/20'
|
||||
default:
|
||||
return 'bg-gray-400/20'
|
||||
}
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func(...args), wait)
|
||||
}
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 15)
|
||||
}
|
||||
347
src/server/jobs/alert-processor.job.ts
Normal file
347
src/server/jobs/alert-processor.job.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { addNotificationJob } from './queue'
|
||||
|
||||
export async function processAlert(alertaId: string) {
|
||||
console.log(`Procesando alerta ${alertaId}`)
|
||||
|
||||
const alerta = await prisma.alerta.findUnique({
|
||||
where: { id: alertaId },
|
||||
include: {
|
||||
cliente: true,
|
||||
dispositivo: true,
|
||||
regla: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!alerta) {
|
||||
console.error(`Alerta ${alertaId} no encontrada`)
|
||||
return
|
||||
}
|
||||
|
||||
// Si ya fue notificada, no volver a procesar
|
||||
if (alerta.notificada) {
|
||||
return
|
||||
}
|
||||
|
||||
// Obtener usuarios que deben ser notificados
|
||||
const usuarios = await prisma.usuario.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ clienteId: alerta.clienteId },
|
||||
{ clienteId: null, rol: { in: ['SUPER_ADMIN', 'ADMIN'] } },
|
||||
],
|
||||
activo: true,
|
||||
},
|
||||
})
|
||||
|
||||
const notificaciones: Promise<unknown>[] = []
|
||||
|
||||
for (const usuario of usuarios) {
|
||||
// Verificar si el usuario quiere notificaciones segun severidad
|
||||
const debeNotificar = shouldNotify(usuario, alerta.severidad)
|
||||
if (!debeNotificar) continue
|
||||
|
||||
// Email
|
||||
if (usuario.notificarEmail && usuario.email) {
|
||||
notificaciones.push(
|
||||
addNotificationJob({
|
||||
type: 'send-email',
|
||||
alertaId: alerta.id,
|
||||
destinatario: usuario.email,
|
||||
titulo: formatAlertTitle(alerta),
|
||||
mensaje: formatAlertMessage(alerta),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// SMS
|
||||
if (usuario.notificarSms && usuario.telefono && alerta.severidad === 'CRITICAL') {
|
||||
notificaciones.push(
|
||||
addNotificationJob({
|
||||
type: 'send-sms',
|
||||
alertaId: alerta.id,
|
||||
destinatario: usuario.telefono,
|
||||
titulo: formatAlertTitle(alerta),
|
||||
mensaje: formatAlertMessageShort(alerta),
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook si la regla lo tiene configurado
|
||||
if (alerta.regla?.notificarWebhook && alerta.regla.webhookUrl) {
|
||||
notificaciones.push(
|
||||
addNotificationJob({
|
||||
type: 'send-webhook',
|
||||
alertaId: alerta.id,
|
||||
destinatario: alerta.regla.webhookUrl,
|
||||
titulo: formatAlertTitle(alerta),
|
||||
mensaje: JSON.stringify({
|
||||
id: alerta.id,
|
||||
severidad: alerta.severidad,
|
||||
titulo: alerta.titulo,
|
||||
mensaje: alerta.mensaje,
|
||||
dispositivo: alerta.dispositivo?.nombre,
|
||||
cliente: alerta.cliente.nombre,
|
||||
timestamp: alerta.createdAt,
|
||||
}),
|
||||
webhookUrl: alerta.regla.webhookUrl,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(notificaciones)
|
||||
|
||||
// Marcar como notificada
|
||||
await prisma.alerta.update({
|
||||
where: { id: alertaId },
|
||||
data: { notificada: true },
|
||||
})
|
||||
|
||||
console.log(`Alerta ${alertaId} procesada, ${notificaciones.length} notificaciones enviadas`)
|
||||
}
|
||||
|
||||
export async function checkAlertRules() {
|
||||
console.log('Verificando reglas de alerta...')
|
||||
|
||||
// Obtener todas las reglas activas
|
||||
const reglas = await prisma.alertaRegla.findMany({
|
||||
where: { activa: true },
|
||||
})
|
||||
|
||||
for (const regla of reglas) {
|
||||
try {
|
||||
await evaluateRule(regla)
|
||||
} catch (error) {
|
||||
console.error(`Error evaluando regla ${regla.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Verificacion de reglas completada')
|
||||
}
|
||||
|
||||
async function evaluateRule(regla: {
|
||||
id: string
|
||||
clienteId: string | null
|
||||
nombre: string
|
||||
tipoDispositivo: string | null
|
||||
metrica: string
|
||||
operador: string
|
||||
umbral: number
|
||||
duracionMinutos: number
|
||||
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
}) {
|
||||
// Obtener dispositivos que aplican a esta regla
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (regla.clienteId) {
|
||||
where.clienteId = regla.clienteId
|
||||
}
|
||||
|
||||
if (regla.tipoDispositivo) {
|
||||
where.tipo = regla.tipoDispositivo
|
||||
}
|
||||
|
||||
where.estado = 'ONLINE'
|
||||
|
||||
const dispositivos = await prisma.dispositivo.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
clienteId: true,
|
||||
cpuUsage: true,
|
||||
ramUsage: true,
|
||||
discoUsage: true,
|
||||
temperatura: true,
|
||||
bateria: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const dispositivo of dispositivos) {
|
||||
const valor = getMetricValue(dispositivo, regla.metrica)
|
||||
if (valor === null) continue
|
||||
|
||||
const cumpleCondicion = evaluateCondition(valor, regla.operador, regla.umbral)
|
||||
|
||||
if (cumpleCondicion) {
|
||||
// Verificar si la condicion se mantiene por el tiempo requerido
|
||||
const metricas = await prisma.dispositivoMetrica.findMany({
|
||||
where: {
|
||||
dispositivoId: dispositivo.id,
|
||||
timestamp: {
|
||||
gte: new Date(Date.now() - regla.duracionMinutos * 60 * 1000),
|
||||
},
|
||||
},
|
||||
orderBy: { timestamp: 'asc' },
|
||||
})
|
||||
|
||||
// Si hay suficientes metricas y todas cumplen la condicion
|
||||
if (metricas.length >= Math.floor(regla.duracionMinutos / 5)) {
|
||||
const todasCumplen = metricas.every((m) => {
|
||||
const v = getMetricValueFromRecord(m, regla.metrica)
|
||||
return v !== null && evaluateCondition(v, regla.operador, regla.umbral)
|
||||
})
|
||||
|
||||
if (todasCumplen) {
|
||||
await createAlertFromRule(regla, dispositivo, valor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricValue(
|
||||
dispositivo: {
|
||||
cpuUsage: number | null
|
||||
ramUsage: number | null
|
||||
discoUsage: number | null
|
||||
temperatura: number | null
|
||||
bateria: number | null
|
||||
},
|
||||
metrica: string
|
||||
): number | null {
|
||||
switch (metrica) {
|
||||
case 'cpu':
|
||||
return dispositivo.cpuUsage
|
||||
case 'ram':
|
||||
return dispositivo.ramUsage
|
||||
case 'disco':
|
||||
return dispositivo.discoUsage
|
||||
case 'temperatura':
|
||||
return dispositivo.temperatura
|
||||
case 'bateria':
|
||||
return dispositivo.bateria
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricValueFromRecord(
|
||||
record: {
|
||||
cpuUsage: number | null
|
||||
ramUsage: number | null
|
||||
discoUsage: number | null
|
||||
temperatura: number | null
|
||||
bateria: number | null
|
||||
},
|
||||
metrica: string
|
||||
): number | null {
|
||||
return getMetricValue(record, metrica)
|
||||
}
|
||||
|
||||
function evaluateCondition(valor: number, operador: string, umbral: number): boolean {
|
||||
switch (operador) {
|
||||
case '>':
|
||||
return valor > umbral
|
||||
case '<':
|
||||
return valor < umbral
|
||||
case '>=':
|
||||
return valor >= umbral
|
||||
case '<=':
|
||||
return valor <= umbral
|
||||
case '==':
|
||||
return valor === umbral
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function createAlertFromRule(
|
||||
regla: {
|
||||
id: string
|
||||
nombre: string
|
||||
metrica: string
|
||||
operador: string
|
||||
umbral: number
|
||||
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
},
|
||||
dispositivo: { id: string; nombre: string; clienteId: string },
|
||||
valor: number
|
||||
) {
|
||||
// Verificar si ya existe una alerta activa para esta regla y dispositivo
|
||||
const existente = await prisma.alerta.findFirst({
|
||||
where: {
|
||||
reglaId: regla.id,
|
||||
dispositivoId: dispositivo.id,
|
||||
estado: 'ACTIVA',
|
||||
},
|
||||
})
|
||||
|
||||
if (existente) return
|
||||
|
||||
const alerta = await prisma.alerta.create({
|
||||
data: {
|
||||
clienteId: dispositivo.clienteId,
|
||||
dispositivoId: dispositivo.id,
|
||||
reglaId: regla.id,
|
||||
severidad: regla.severidad,
|
||||
titulo: `${regla.nombre}: ${dispositivo.nombre}`,
|
||||
mensaje: `${regla.metrica} es ${valor} (umbral: ${regla.operador} ${regla.umbral})`,
|
||||
origen: 'sistema',
|
||||
},
|
||||
})
|
||||
|
||||
await addNotificationJob({
|
||||
type: 'send-email',
|
||||
alertaId: alerta.id,
|
||||
destinatario: '', // Se determinara en el procesador
|
||||
titulo: '',
|
||||
mensaje: '',
|
||||
})
|
||||
}
|
||||
|
||||
function shouldNotify(
|
||||
usuario: { rol: string },
|
||||
severidad: string
|
||||
): boolean {
|
||||
// Super admins y admins reciben todas las alertas
|
||||
if (['SUPER_ADMIN', 'ADMIN'].includes(usuario.rol)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tecnicos reciben warning y critical
|
||||
if (usuario.rol === 'TECNICO') {
|
||||
return ['WARNING', 'CRITICAL'].includes(severidad)
|
||||
}
|
||||
|
||||
// Clientes solo reciben critical
|
||||
if (usuario.rol === 'CLIENTE') {
|
||||
return severidad === 'CRITICAL'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function formatAlertTitle(alerta: {
|
||||
severidad: string
|
||||
titulo: string
|
||||
cliente: { nombre: string }
|
||||
}): string {
|
||||
const emoji =
|
||||
alerta.severidad === 'CRITICAL' ? '🔴' : alerta.severidad === 'WARNING' ? '🟡' : '🔵'
|
||||
return `${emoji} [${alerta.cliente.nombre}] ${alerta.titulo}`
|
||||
}
|
||||
|
||||
function formatAlertMessage(alerta: {
|
||||
mensaje: string
|
||||
dispositivo: { nombre: string } | null
|
||||
createdAt: Date
|
||||
}): string {
|
||||
return `
|
||||
${alerta.mensaje}
|
||||
|
||||
Dispositivo: ${alerta.dispositivo?.nombre || 'N/A'}
|
||||
Fecha: ${alerta.createdAt.toLocaleString('es-MX')}
|
||||
|
||||
---
|
||||
MSP Monitor Dashboard
|
||||
`.trim()
|
||||
}
|
||||
|
||||
function formatAlertMessageShort(alerta: {
|
||||
titulo: string
|
||||
dispositivo: { nombre: string } | null
|
||||
}): string {
|
||||
return `${alerta.titulo} - ${alerta.dispositivo?.nombre || 'N/A'}`
|
||||
}
|
||||
218
src/server/jobs/maintenance.job.ts
Normal file
218
src/server/jobs/maintenance.job.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { MeshCentralClient } from '../services/meshcentral/client'
|
||||
import { LibreNMSClient } from '../services/librenms/client'
|
||||
import { HeadwindClient } from '../services/headwind/client'
|
||||
|
||||
export async function cleanupMetrics(olderThanDays: number): Promise<void> {
|
||||
console.log(`Limpiando metricas de mas de ${olderThanDays} dias...`)
|
||||
|
||||
const fecha = new Date()
|
||||
fecha.setDate(fecha.getDate() - olderThanDays)
|
||||
|
||||
// Eliminar metricas detalladas antiguas
|
||||
const deletedMetrics = await prisma.dispositivoMetrica.deleteMany({
|
||||
where: {
|
||||
timestamp: { lt: fecha },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Eliminadas ${deletedMetrics.count} metricas detalladas`)
|
||||
|
||||
// Eliminar metricas hourly de mas de 1 año
|
||||
const yearAgo = new Date()
|
||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
|
||||
|
||||
const deletedHourly = await prisma.dispositivoMetricaHourly.deleteMany({
|
||||
where: {
|
||||
hora: { lt: yearAgo },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Eliminadas ${deletedHourly.count} metricas hourly`)
|
||||
|
||||
// Eliminar audit logs antiguos (mas de 6 meses)
|
||||
const sixMonthsAgo = new Date()
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
|
||||
|
||||
const deletedLogs = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
createdAt: { lt: sixMonthsAgo },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Eliminados ${deletedLogs.count} audit logs`)
|
||||
|
||||
// Eliminar alertas resueltas de mas de 30 dias
|
||||
const thirtyDaysAgo = new Date()
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
||||
|
||||
const deletedAlerts = await prisma.alerta.deleteMany({
|
||||
where: {
|
||||
estado: 'RESUELTA',
|
||||
resueltaEn: { lt: thirtyDaysAgo },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Eliminadas ${deletedAlerts.count} alertas resueltas`)
|
||||
|
||||
console.log('Limpieza de metricas completada')
|
||||
}
|
||||
|
||||
export async function aggregateMetrics(): Promise<void> {
|
||||
console.log('Agregando metricas por hora...')
|
||||
|
||||
// Obtener la hora actual truncada
|
||||
const now = new Date()
|
||||
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours())
|
||||
const previousHour = new Date(currentHour.getTime() - 60 * 60 * 1000)
|
||||
|
||||
// Obtener todos los dispositivos con metricas en la hora anterior
|
||||
const dispositivos = await prisma.dispositivo.findMany({
|
||||
where: {
|
||||
metricas: {
|
||||
some: {
|
||||
timestamp: {
|
||||
gte: previousHour,
|
||||
lt: currentHour,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const dispositivo of dispositivos) {
|
||||
// Verificar si ya existe agregacion para esta hora
|
||||
const existente = await prisma.dispositivoMetricaHourly.findUnique({
|
||||
where: {
|
||||
dispositivoId_hora: {
|
||||
dispositivoId: dispositivo.id,
|
||||
hora: previousHour,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existente) continue
|
||||
|
||||
// Calcular agregaciones
|
||||
const metricas = await prisma.dispositivoMetrica.findMany({
|
||||
where: {
|
||||
dispositivoId: dispositivo.id,
|
||||
timestamp: {
|
||||
gte: previousHour,
|
||||
lt: currentHour,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (metricas.length === 0) continue
|
||||
|
||||
const cpuValues = metricas.map((m) => m.cpuUsage).filter((v): v is number => v !== null)
|
||||
const ramValues = metricas.map((m) => m.ramUsage).filter((v): v is number => v !== null)
|
||||
const discoValues = metricas.map((m) => m.discoUsage).filter((v): v is number => v !== null)
|
||||
const tempValues = metricas.map((m) => m.temperatura).filter((v): v is number => v !== null)
|
||||
const redInValues = metricas.map((m) => m.redIn).filter((v): v is bigint => v !== null)
|
||||
const redOutValues = metricas.map((m) => m.redOut).filter((v): v is bigint => v !== null)
|
||||
|
||||
await prisma.dispositivoMetricaHourly.create({
|
||||
data: {
|
||||
dispositivoId: dispositivo.id,
|
||||
hora: previousHour,
|
||||
cpuAvg: cpuValues.length > 0 ? average(cpuValues) : null,
|
||||
cpuMax: cpuValues.length > 0 ? Math.max(...cpuValues) : null,
|
||||
ramAvg: ramValues.length > 0 ? average(ramValues) : null,
|
||||
ramMax: ramValues.length > 0 ? Math.max(...ramValues) : null,
|
||||
discoAvg: discoValues.length > 0 ? average(discoValues) : null,
|
||||
tempAvg: tempValues.length > 0 ? average(tempValues) : null,
|
||||
tempMax: tempValues.length > 0 ? Math.max(...tempValues) : null,
|
||||
redInTotal: redInValues.length > 0 ? sumBigInt(redInValues) : null,
|
||||
redOutTotal: redOutValues.length > 0 ? sumBigInt(redOutValues) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Metricas agregadas para ${dispositivos.length} dispositivos`)
|
||||
}
|
||||
|
||||
export async function healthCheck(): Promise<void> {
|
||||
console.log('Ejecutando health check...')
|
||||
|
||||
const results: Record<string, boolean> = {}
|
||||
|
||||
// Check base de datos
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
results.database = true
|
||||
} catch {
|
||||
results.database = false
|
||||
console.error('Health check: Base de datos no disponible')
|
||||
}
|
||||
|
||||
// Check MeshCentral
|
||||
try {
|
||||
const meshClient = new MeshCentralClient()
|
||||
await meshClient.getDevices()
|
||||
results.meshcentral = true
|
||||
} catch {
|
||||
results.meshcentral = false
|
||||
console.warn('Health check: MeshCentral no disponible')
|
||||
}
|
||||
|
||||
// Check LibreNMS
|
||||
try {
|
||||
const librenmsClient = new LibreNMSClient()
|
||||
const connected = await librenmsClient.testConnection()
|
||||
results.librenms = connected
|
||||
if (!connected) {
|
||||
console.warn('Health check: LibreNMS no disponible')
|
||||
}
|
||||
} catch {
|
||||
results.librenms = false
|
||||
console.warn('Health check: LibreNMS no disponible')
|
||||
}
|
||||
|
||||
// Check Headwind
|
||||
try {
|
||||
const headwindClient = new HeadwindClient()
|
||||
const connected = await headwindClient.testConnection()
|
||||
results.headwind = connected
|
||||
if (!connected) {
|
||||
console.warn('Health check: Headwind MDM no disponible')
|
||||
}
|
||||
} catch {
|
||||
results.headwind = false
|
||||
console.warn('Health check: Headwind MDM no disponible')
|
||||
}
|
||||
|
||||
// Guardar resultados en configuracion
|
||||
await prisma.configuracion.upsert({
|
||||
where: { clave: 'health_check_results' },
|
||||
update: {
|
||||
valor: {
|
||||
...results,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
clave: 'health_check_results',
|
||||
valor: {
|
||||
...results,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
tipo: 'json',
|
||||
categoria: 'sistema',
|
||||
descripcion: 'Resultados del ultimo health check',
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Health check completado:', results)
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
if (values.length === 0) return 0
|
||||
return values.reduce((a, b) => a + b, 0) / values.length
|
||||
}
|
||||
|
||||
function sumBigInt(values: bigint[]): bigint {
|
||||
return values.reduce((a, b) => a + b, BigInt(0))
|
||||
}
|
||||
184
src/server/jobs/notification.job.ts
Normal file
184
src/server/jobs/notification.job.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import Twilio from 'twilio'
|
||||
import { NotificationJobData } from './queue'
|
||||
|
||||
// Configurar transporter de email
|
||||
let emailTransporter: nodemailer.Transporter | null = null
|
||||
|
||||
function getEmailTransporter(): nodemailer.Transporter {
|
||||
if (!emailTransporter) {
|
||||
emailTransporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_PORT === '465',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
})
|
||||
}
|
||||
return emailTransporter
|
||||
}
|
||||
|
||||
// Configurar cliente de Twilio
|
||||
let twilioClient: Twilio.Twilio | null = null
|
||||
|
||||
function getTwilioClient(): Twilio.Twilio | null {
|
||||
if (!twilioClient && process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN) {
|
||||
twilioClient = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
|
||||
}
|
||||
return twilioClient
|
||||
}
|
||||
|
||||
export async function sendEmail(data: NotificationJobData): Promise<void> {
|
||||
const transporter = getEmailTransporter()
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || 'MSP Monitor <noreply@localhost>',
|
||||
to: data.destinatario,
|
||||
subject: data.titulo,
|
||||
text: data.mensaje,
|
||||
html: formatEmailHtml(data.titulo, data.mensaje),
|
||||
})
|
||||
|
||||
console.log(`Email enviado a ${data.destinatario}`)
|
||||
} catch (error) {
|
||||
console.error(`Error enviando email a ${data.destinatario}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSMS(data: NotificationJobData): Promise<void> {
|
||||
const client = getTwilioClient()
|
||||
|
||||
if (!client) {
|
||||
console.warn('Twilio no configurado, SMS no enviado')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await client.messages.create({
|
||||
body: `${data.titulo}\n${data.mensaje}`,
|
||||
from: process.env.TWILIO_PHONE_NUMBER,
|
||||
to: data.destinatario,
|
||||
})
|
||||
|
||||
console.log(`SMS enviado a ${data.destinatario}`)
|
||||
} catch (error) {
|
||||
console.error(`Error enviando SMS a ${data.destinatario}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWebhook(data: NotificationJobData): Promise<void> {
|
||||
if (!data.webhookUrl) {
|
||||
console.warn('URL de webhook no especificada')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(data.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data.mensaje,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook respondio con status ${response.status}`)
|
||||
}
|
||||
|
||||
console.log(`Webhook enviado a ${data.webhookUrl}`)
|
||||
} catch (error) {
|
||||
console.error(`Error enviando webhook a ${data.webhookUrl}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function formatEmailHtml(titulo: string, mensaje: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0d1321 0%, #1a2234 100%);
|
||||
color: #06b6d4;
|
||||
padding: 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.alert-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.alert-message {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #06b6d4;
|
||||
margin: 15px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.severity-critical { border-left-color: #ef4444; }
|
||||
.severity-warning { border-left-color: #f59e0b; }
|
||||
.severity-info { border-left-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>MSP Monitor</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="alert-title">${escapeHtml(titulo)}</div>
|
||||
<div class="alert-message">${escapeHtml(mensaje)}</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Este es un mensaje automatico del sistema MSP Monitor.<br>
|
||||
No responda a este correo.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim()
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
168
src/server/jobs/queue.ts
Normal file
168
src/server/jobs/queue.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Queue, Worker, Job } from 'bullmq'
|
||||
import Redis from 'ioredis'
|
||||
|
||||
const connection = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
})
|
||||
|
||||
// Colas de trabajo
|
||||
export const syncQueue = new Queue('sync', { connection })
|
||||
export const alertQueue = new Queue('alerts', { connection })
|
||||
export const notificationQueue = new Queue('notifications', { connection })
|
||||
export const maintenanceQueue = new Queue('maintenance', { connection })
|
||||
|
||||
// Tipos de trabajos
|
||||
export type SyncJobType = 'sync-meshcentral' | 'sync-librenms' | 'sync-headwind'
|
||||
export type AlertJobType = 'process-alert' | 'check-rules'
|
||||
export type NotificationJobType = 'send-email' | 'send-sms' | 'send-webhook'
|
||||
export type MaintenanceJobType = 'cleanup-metrics' | 'aggregate-metrics' | 'health-check'
|
||||
|
||||
// Interfaces para datos de trabajos
|
||||
export interface SyncJobData {
|
||||
type: SyncJobType
|
||||
clienteId?: string
|
||||
}
|
||||
|
||||
export interface AlertJobData {
|
||||
type: AlertJobType
|
||||
alertaId?: string
|
||||
dispositivoId?: string
|
||||
}
|
||||
|
||||
export interface NotificationJobData {
|
||||
type: NotificationJobType
|
||||
alertaId: string
|
||||
destinatario: string
|
||||
titulo: string
|
||||
mensaje: string
|
||||
webhookUrl?: string
|
||||
}
|
||||
|
||||
export interface MaintenanceJobData {
|
||||
type: MaintenanceJobType
|
||||
olderThanDays?: number
|
||||
}
|
||||
|
||||
// Agregar trabajo de sincronizacion
|
||||
export async function addSyncJob(data: SyncJobData, delay = 0) {
|
||||
return syncQueue.add(data.type, data, {
|
||||
delay,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Agregar trabajo de alerta
|
||||
export async function addAlertJob(data: AlertJobData) {
|
||||
return alertQueue.add(data.type, data, {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Agregar trabajo de notificacion
|
||||
export async function addNotificationJob(data: NotificationJobData) {
|
||||
return notificationQueue.add(data.type, data, {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Agregar trabajo de mantenimiento
|
||||
export async function addMaintenanceJob(data: MaintenanceJobData, delay = 0) {
|
||||
return maintenanceQueue.add(data.type, data, {
|
||||
delay,
|
||||
attempts: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// Programar trabajos recurrentes
|
||||
export async function scheduleRecurringJobs() {
|
||||
// Sincronizar cada 5 minutos
|
||||
await syncQueue.add(
|
||||
'sync-meshcentral',
|
||||
{ type: 'sync-meshcentral' as SyncJobType },
|
||||
{
|
||||
repeat: { every: 5 * 60 * 1000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
}
|
||||
)
|
||||
|
||||
await syncQueue.add(
|
||||
'sync-librenms',
|
||||
{ type: 'sync-librenms' as SyncJobType },
|
||||
{
|
||||
repeat: { every: 5 * 60 * 1000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
}
|
||||
)
|
||||
|
||||
await syncQueue.add(
|
||||
'sync-headwind',
|
||||
{ type: 'sync-headwind' as SyncJobType },
|
||||
{
|
||||
repeat: { every: 5 * 60 * 1000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
}
|
||||
)
|
||||
|
||||
// Verificar reglas de alerta cada minuto
|
||||
await alertQueue.add(
|
||||
'check-rules',
|
||||
{ type: 'check-rules' as AlertJobType },
|
||||
{
|
||||
repeat: { every: 60 * 1000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
}
|
||||
)
|
||||
|
||||
// Mantenimiento diario a las 3am
|
||||
await maintenanceQueue.add(
|
||||
'cleanup-metrics',
|
||||
{ type: 'cleanup-metrics' as MaintenanceJobType, olderThanDays: 90 },
|
||||
{
|
||||
repeat: { pattern: '0 3 * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 10,
|
||||
}
|
||||
)
|
||||
|
||||
// Agregar metricas horarias cada hora
|
||||
await maintenanceQueue.add(
|
||||
'aggregate-metrics',
|
||||
{ type: 'aggregate-metrics' as MaintenanceJobType },
|
||||
{
|
||||
repeat: { pattern: '0 * * * *' },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
}
|
||||
)
|
||||
|
||||
// Health check cada 10 minutos
|
||||
await maintenanceQueue.add(
|
||||
'health-check',
|
||||
{ type: 'health-check' as MaintenanceJobType },
|
||||
{
|
||||
repeat: { every: 10 * 60 * 1000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Trabajos recurrentes programados')
|
||||
}
|
||||
|
||||
export { connection, Job }
|
||||
209
src/server/jobs/sync-headwind.job.ts
Normal file
209
src/server/jobs/sync-headwind.job.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { HeadwindClient } from '../services/headwind/client'
|
||||
import { addAlertJob } from './queue'
|
||||
|
||||
export async function syncHeadwind(clienteId?: string) {
|
||||
console.log('Sincronizando con Headwind MDM...')
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
|
||||
try {
|
||||
// Obtener dispositivos de Headwind
|
||||
const headwindDevices = await headwindClient.getDevices()
|
||||
console.log(`Encontrados ${headwindDevices.length} dispositivos en Headwind MDM`)
|
||||
|
||||
// Obtener mapeo de grupos a clientes
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: clienteId ? { id: clienteId } : { activo: true },
|
||||
select: { id: true, headwindGrupo: true },
|
||||
})
|
||||
|
||||
const defaultClienteId = clientes[0]?.id
|
||||
|
||||
for (const device of headwindDevices) {
|
||||
try {
|
||||
// Determinar tipo de dispositivo
|
||||
const tipo: 'CELULAR' | 'TABLET' = device.model?.toLowerCase().includes('tablet') ? 'TABLET' : 'CELULAR'
|
||||
|
||||
// Determinar estado basado en lastUpdate
|
||||
const lastUpdateTime = device.lastUpdate * 1000 // Convertir a milliseconds
|
||||
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000
|
||||
|
||||
let estado: 'ONLINE' | 'OFFLINE' | 'ALERTA' = 'OFFLINE'
|
||||
if (lastUpdateTime > fiveMinutesAgo) {
|
||||
estado = 'ONLINE'
|
||||
} else if (lastUpdateTime > oneHourAgo) {
|
||||
estado = 'ALERTA' // Dispositivo no reporta pero estuvo activo recientemente
|
||||
}
|
||||
|
||||
// Buscar dispositivo existente
|
||||
const existente = await prisma.dispositivo.findUnique({
|
||||
where: { headwindId: device.id },
|
||||
})
|
||||
|
||||
const estadoAnterior = existente?.estado
|
||||
|
||||
// Determinar cliente
|
||||
let clienteIdForDevice = defaultClienteId
|
||||
// TODO: Implementar mapeo por grupo de Headwind
|
||||
|
||||
if (!clienteIdForDevice) {
|
||||
console.warn(`No hay cliente para dispositivo Headwind ${device.number}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const dispositivoData = {
|
||||
nombre: device.description || `${device.manufacturer} ${device.model}`,
|
||||
tipo,
|
||||
estado,
|
||||
imei: device.imei || null,
|
||||
numeroTelefono: device.phone || null,
|
||||
sistemaOperativo: 'Android',
|
||||
versionSO: device.osVersion || null,
|
||||
fabricante: device.manufacturer || null,
|
||||
modelo: device.model || null,
|
||||
bateria: device.batteryLevel || null,
|
||||
latitud: device.lat || null,
|
||||
longitud: device.lon || null,
|
||||
gpsUpdatedAt: device.lat && device.lon ? new Date(lastUpdateTime) : null,
|
||||
lastSeen: new Date(lastUpdateTime),
|
||||
}
|
||||
|
||||
if (existente) {
|
||||
await prisma.dispositivo.update({
|
||||
where: { id: existente.id },
|
||||
data: dispositivoData,
|
||||
})
|
||||
|
||||
// Verificar cambio de estado para alerta
|
||||
if (estadoAnterior === 'ONLINE' && estado === 'OFFLINE') {
|
||||
await crearAlertaDesconexion(existente.id, existente.clienteId, dispositivoData.nombre)
|
||||
}
|
||||
|
||||
// Verificar bateria baja
|
||||
if (device.batteryLevel !== null && device.batteryLevel < 15) {
|
||||
await crearAlertaBateriaBaja(existente.id, existente.clienteId, dispositivoData.nombre, device.batteryLevel)
|
||||
}
|
||||
} else {
|
||||
await prisma.dispositivo.create({
|
||||
data: {
|
||||
...dispositivoData,
|
||||
clienteId: clienteIdForDevice,
|
||||
headwindId: device.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Guardar metricas
|
||||
if (existente) {
|
||||
await prisma.dispositivoMetrica.create({
|
||||
data: {
|
||||
dispositivoId: existente.id,
|
||||
bateria: device.batteryLevel || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Sincronizar aplicaciones instaladas
|
||||
if (existente) {
|
||||
await syncDeviceApps(headwindClient, device.id, existente.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sincronizando dispositivo Headwind ${device.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Sincronizacion con Headwind MDM completada')
|
||||
} catch (error) {
|
||||
console.error('Error en sincronizacion con Headwind MDM:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDeviceApps(client: HeadwindClient, headwindId: number, dispositivoId: string) {
|
||||
try {
|
||||
const apps = await client.getDeviceApps(headwindId)
|
||||
|
||||
// Eliminar apps anteriores y agregar nuevas
|
||||
await prisma.dispositivoSoftware.deleteMany({
|
||||
where: { dispositivoId },
|
||||
})
|
||||
|
||||
if (apps.length > 0) {
|
||||
await prisma.dispositivoSoftware.createMany({
|
||||
data: apps.map((app) => ({
|
||||
dispositivoId,
|
||||
nombre: app.name,
|
||||
version: app.version,
|
||||
editor: app.pkg,
|
||||
})),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sincronizando apps de dispositivo ${headwindId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async function crearAlertaDesconexion(dispositivoId: string, clienteId: string, nombre: string) {
|
||||
// Verificar si ya existe una alerta activa de desconexion
|
||||
const alertaExistente = await prisma.alerta.findFirst({
|
||||
where: {
|
||||
dispositivoId,
|
||||
titulo: { contains: 'desconectado' },
|
||||
estado: 'ACTIVA',
|
||||
},
|
||||
})
|
||||
|
||||
if (alertaExistente) return
|
||||
|
||||
const alerta = await prisma.alerta.create({
|
||||
data: {
|
||||
clienteId,
|
||||
dispositivoId,
|
||||
severidad: 'WARNING',
|
||||
titulo: 'Dispositivo movil desconectado',
|
||||
mensaje: `El dispositivo ${nombre} no ha reportado en mas de 1 hora`,
|
||||
origen: 'headwind',
|
||||
},
|
||||
})
|
||||
|
||||
await addAlertJob({
|
||||
type: 'process-alert',
|
||||
alertaId: alerta.id,
|
||||
})
|
||||
}
|
||||
|
||||
async function crearAlertaBateriaBaja(
|
||||
dispositivoId: string,
|
||||
clienteId: string,
|
||||
nombre: string,
|
||||
nivel: number
|
||||
) {
|
||||
// Verificar si ya existe una alerta activa de bateria
|
||||
const alertaExistente = await prisma.alerta.findFirst({
|
||||
where: {
|
||||
dispositivoId,
|
||||
titulo: { contains: 'bateria' },
|
||||
estado: 'ACTIVA',
|
||||
},
|
||||
})
|
||||
|
||||
if (alertaExistente) return
|
||||
|
||||
const alerta = await prisma.alerta.create({
|
||||
data: {
|
||||
clienteId,
|
||||
dispositivoId,
|
||||
severidad: nivel < 5 ? 'CRITICAL' : 'WARNING',
|
||||
titulo: 'Bateria baja',
|
||||
mensaje: `El dispositivo ${nombre} tiene bateria al ${nivel}%`,
|
||||
origen: 'headwind',
|
||||
},
|
||||
})
|
||||
|
||||
await addAlertJob({
|
||||
type: 'process-alert',
|
||||
alertaId: alerta.id,
|
||||
})
|
||||
}
|
||||
232
src/server/jobs/sync-librenms.job.ts
Normal file
232
src/server/jobs/sync-librenms.job.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { LibreNMSClient } from '../services/librenms/client'
|
||||
import { addAlertJob } from './queue'
|
||||
|
||||
export async function syncLibreNMS(clienteId?: string) {
|
||||
console.log('Sincronizando con LibreNMS...')
|
||||
|
||||
const librenmsClient = new LibreNMSClient()
|
||||
|
||||
try {
|
||||
// Obtener dispositivos de LibreNMS
|
||||
const librenmsDevices = await librenmsClient.getDevices()
|
||||
console.log(`Encontrados ${librenmsDevices.length} dispositivos en LibreNMS`)
|
||||
|
||||
// Obtener mapeo de grupos a clientes
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: clienteId ? { id: clienteId } : { activo: true },
|
||||
select: { id: true, librenmsGrupo: true },
|
||||
})
|
||||
|
||||
// Por simplicidad, si no hay grupo asignado, usar el primer cliente activo
|
||||
const defaultClienteId = clientes[0]?.id
|
||||
|
||||
for (const device of librenmsDevices) {
|
||||
try {
|
||||
// Determinar tipo de dispositivo basado en OS
|
||||
let tipo: 'ROUTER' | 'SWITCH' | 'FIREWALL' | 'AP' | 'IMPRESORA' | 'OTRO' = 'OTRO'
|
||||
const os = (device.os || '').toLowerCase()
|
||||
const hardware = (device.hardware || '').toLowerCase()
|
||||
|
||||
if (os.includes('ios') || os.includes('routeros') || hardware.includes('router')) {
|
||||
tipo = 'ROUTER'
|
||||
} else if (os.includes('switch') || hardware.includes('switch')) {
|
||||
tipo = 'SWITCH'
|
||||
} else if (os.includes('pfsense') || os.includes('fortigate') || os.includes('asa')) {
|
||||
tipo = 'FIREWALL'
|
||||
} else if (os.includes('airos') || os.includes('unifi') || hardware.includes('access point')) {
|
||||
tipo = 'AP'
|
||||
} else if (os.includes('printer') || hardware.includes('printer')) {
|
||||
tipo = 'IMPRESORA'
|
||||
}
|
||||
|
||||
// Determinar estado
|
||||
let estado: 'ONLINE' | 'OFFLINE' | 'ALERTA' = 'OFFLINE'
|
||||
if (device.status === 1) {
|
||||
estado = 'ONLINE'
|
||||
} else if (device.status === 2) {
|
||||
estado = 'ALERTA'
|
||||
}
|
||||
|
||||
// Buscar dispositivo existente
|
||||
const existente = await prisma.dispositivo.findUnique({
|
||||
where: { librenmsId: device.device_id },
|
||||
})
|
||||
|
||||
const estadoAnterior = existente?.estado
|
||||
|
||||
// Determinar cliente
|
||||
let clienteIdForDevice = defaultClienteId
|
||||
// TODO: Implementar mapeo por grupo de LibreNMS
|
||||
|
||||
if (!clienteIdForDevice) {
|
||||
console.warn(`No hay cliente para dispositivo LibreNMS ${device.hostname}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const dispositivoData = {
|
||||
nombre: device.sysName || device.hostname,
|
||||
tipo,
|
||||
estado,
|
||||
ip: device.ip || null,
|
||||
sistemaOperativo: device.os || null,
|
||||
versionSO: device.version || null,
|
||||
fabricante: null, // LibreNMS no siempre provee esto directamente
|
||||
modelo: device.hardware || null,
|
||||
serial: device.serial || null,
|
||||
firmware: device.version || null,
|
||||
lastSeen: estado === 'ONLINE' ? new Date() : existente?.lastSeen,
|
||||
}
|
||||
|
||||
if (existente) {
|
||||
await prisma.dispositivo.update({
|
||||
where: { id: existente.id },
|
||||
data: dispositivoData,
|
||||
})
|
||||
|
||||
// Verificar cambio de estado para alerta
|
||||
if (estadoAnterior === 'ONLINE' && estado === 'OFFLINE') {
|
||||
await crearAlertaDesconexion(existente.id, existente.clienteId, device.sysName || device.hostname)
|
||||
}
|
||||
} else {
|
||||
await prisma.dispositivo.create({
|
||||
data: {
|
||||
...dispositivoData,
|
||||
clienteId: clienteIdForDevice,
|
||||
librenmsId: device.device_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Guardar metricas de sensores si esta online
|
||||
if (estado === 'ONLINE' && existente) {
|
||||
const sensors = await librenmsClient.getDeviceSensors(device.device_id)
|
||||
|
||||
let cpuUsage: number | null = null
|
||||
let ramUsage: number | null = null
|
||||
let temperatura: number | null = null
|
||||
|
||||
for (const sensor of sensors) {
|
||||
if (sensor.sensor_class === 'processor') {
|
||||
cpuUsage = sensor.sensor_current
|
||||
} else if (sensor.sensor_class === 'memory') {
|
||||
ramUsage = sensor.sensor_current
|
||||
} else if (sensor.sensor_class === 'temperature') {
|
||||
if (temperatura === null || sensor.sensor_current > temperatura) {
|
||||
temperatura = sensor.sensor_current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cpuUsage !== null || ramUsage !== null || temperatura !== null) {
|
||||
await prisma.dispositivoMetrica.create({
|
||||
data: {
|
||||
dispositivoId: existente.id,
|
||||
cpuUsage,
|
||||
ramUsage,
|
||||
temperatura,
|
||||
},
|
||||
})
|
||||
|
||||
// Actualizar metricas actuales en dispositivo
|
||||
await prisma.dispositivo.update({
|
||||
where: { id: existente.id },
|
||||
data: {
|
||||
cpuUsage,
|
||||
ramUsage,
|
||||
temperatura,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sincronizando dispositivo LibreNMS ${device.device_id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar alertas de LibreNMS
|
||||
await syncLibreNMSAlerts(librenmsClient)
|
||||
|
||||
console.log('Sincronizacion con LibreNMS completada')
|
||||
} catch (error) {
|
||||
console.error('Error en sincronizacion con LibreNMS:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLibreNMSAlerts(client: LibreNMSClient) {
|
||||
try {
|
||||
const alerts = await client.getAlerts()
|
||||
|
||||
for (const alert of alerts) {
|
||||
// Buscar dispositivo asociado
|
||||
const dispositivo = await prisma.dispositivo.findUnique({
|
||||
where: { librenmsId: alert.device_id },
|
||||
})
|
||||
|
||||
if (!dispositivo) continue
|
||||
|
||||
// Verificar si ya existe esta alerta
|
||||
const existente = await prisma.alerta.findFirst({
|
||||
where: {
|
||||
origen: 'librenms',
|
||||
origenId: String(alert.id),
|
||||
},
|
||||
})
|
||||
|
||||
if (!existente) {
|
||||
// Crear nueva alerta
|
||||
const nuevaAlerta = await prisma.alerta.create({
|
||||
data: {
|
||||
clienteId: dispositivo.clienteId,
|
||||
dispositivoId: dispositivo.id,
|
||||
severidad: mapSeveridad(alert.severity),
|
||||
titulo: `Alerta LibreNMS: ${alert.info}`,
|
||||
mensaje: alert.info,
|
||||
origen: 'librenms',
|
||||
origenId: String(alert.id),
|
||||
},
|
||||
})
|
||||
|
||||
await addAlertJob({
|
||||
type: 'process-alert',
|
||||
alertaId: nuevaAlerta.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sincronizando alertas de LibreNMS:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function mapSeveridad(severity: string): 'INFO' | 'WARNING' | 'CRITICAL' {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'alert':
|
||||
case 'emergency':
|
||||
return 'CRITICAL'
|
||||
case 'warning':
|
||||
case 'warn':
|
||||
return 'WARNING'
|
||||
default:
|
||||
return 'INFO'
|
||||
}
|
||||
}
|
||||
|
||||
async function crearAlertaDesconexion(dispositivoId: string, clienteId: string, nombre: string) {
|
||||
const alerta = await prisma.alerta.create({
|
||||
data: {
|
||||
clienteId,
|
||||
dispositivoId,
|
||||
severidad: 'CRITICAL',
|
||||
titulo: 'Dispositivo de red desconectado',
|
||||
mensaje: `El dispositivo de red ${nombre} no responde`,
|
||||
origen: 'librenms',
|
||||
},
|
||||
})
|
||||
|
||||
await addAlertJob({
|
||||
type: 'process-alert',
|
||||
alertaId: alerta.id,
|
||||
})
|
||||
}
|
||||
150
src/server/jobs/sync-meshcentral.job.ts
Normal file
150
src/server/jobs/sync-meshcentral.job.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { MeshCentralClient } from '../services/meshcentral/client'
|
||||
import { addAlertJob } from './queue'
|
||||
|
||||
export async function syncMeshCentral(clienteId?: string) {
|
||||
console.log('Sincronizando con MeshCentral...')
|
||||
|
||||
const meshClient = new MeshCentralClient()
|
||||
|
||||
try {
|
||||
// Obtener dispositivos de MeshCentral
|
||||
const meshDevices = await meshClient.getDevices()
|
||||
console.log(`Encontrados ${meshDevices.length} dispositivos en MeshCentral`)
|
||||
|
||||
// Obtener mapeo de grupos a clientes
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: clienteId ? { id: clienteId } : { activo: true },
|
||||
select: { id: true, meshcentralGrupo: true },
|
||||
})
|
||||
|
||||
const grupoToCliente = new Map<string, string>()
|
||||
clientes.forEach((c) => {
|
||||
if (c.meshcentralGrupo) {
|
||||
grupoToCliente.set(c.meshcentralGrupo, c.id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const meshDevice of meshDevices) {
|
||||
try {
|
||||
// Determinar cliente basado en grupo
|
||||
const meshId = meshDevice._id.split('/')[0] // El mesh ID esta antes del /
|
||||
const clienteIdForDevice = grupoToCliente.get(meshId)
|
||||
|
||||
if (!clienteIdForDevice && clienteId) {
|
||||
continue // Si estamos sincronizando un cliente especifico, ignorar dispositivos de otros
|
||||
}
|
||||
|
||||
// Determinar tipo de dispositivo
|
||||
let tipo: 'PC' | 'LAPTOP' | 'SERVIDOR' = 'PC'
|
||||
const osDesc = (meshDevice.osdesc || '').toLowerCase()
|
||||
if (osDesc.includes('server')) {
|
||||
tipo = 'SERVIDOR'
|
||||
} else if (osDesc.includes('laptop') || osDesc.includes('notebook')) {
|
||||
tipo = 'LAPTOP'
|
||||
}
|
||||
|
||||
// Determinar estado
|
||||
let estado: 'ONLINE' | 'OFFLINE' | 'DESCONOCIDO' = 'DESCONOCIDO'
|
||||
if (meshDevice.conn && meshDevice.conn > 0) {
|
||||
estado = 'ONLINE'
|
||||
} else {
|
||||
estado = 'OFFLINE'
|
||||
}
|
||||
|
||||
// Buscar dispositivo existente o crear nuevo
|
||||
const existente = await prisma.dispositivo.findUnique({
|
||||
where: { meshcentralId: meshDevice._id },
|
||||
})
|
||||
|
||||
const estadoAnterior = existente?.estado
|
||||
|
||||
const dispositivoData = {
|
||||
nombre: meshDevice.name,
|
||||
tipo,
|
||||
estado,
|
||||
ip: meshDevice.ip || null,
|
||||
sistemaOperativo: meshDevice.osdesc || null,
|
||||
lastSeen: estado === 'ONLINE' ? new Date() : existente?.lastSeen,
|
||||
}
|
||||
|
||||
if (existente) {
|
||||
await prisma.dispositivo.update({
|
||||
where: { id: existente.id },
|
||||
data: dispositivoData,
|
||||
})
|
||||
|
||||
// Verificar cambio de estado para alerta
|
||||
if (estadoAnterior === 'ONLINE' && estado === 'OFFLINE') {
|
||||
await crearAlertaDesconexion(existente.id, existente.clienteId)
|
||||
}
|
||||
} else if (clienteIdForDevice) {
|
||||
await prisma.dispositivo.create({
|
||||
data: {
|
||||
...dispositivoData,
|
||||
clienteId: clienteIdForDevice,
|
||||
meshcentralId: meshDevice._id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Guardar metricas si esta online
|
||||
if (estado === 'ONLINE' && existente) {
|
||||
// Obtener metricas detalladas de MeshCentral
|
||||
// Por ahora solo guardamos que esta online
|
||||
await prisma.dispositivoMetrica.create({
|
||||
data: {
|
||||
dispositivoId: existente.id,
|
||||
cpuUsage: null, // MeshCentral no provee CPU en tiempo real por defecto
|
||||
ramUsage: null,
|
||||
discoUsage: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sincronizando dispositivo ${meshDevice._id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Marcar como offline dispositivos que ya no estan en MeshCentral
|
||||
const meshIds = meshDevices.map((d) => d._id)
|
||||
await prisma.dispositivo.updateMany({
|
||||
where: {
|
||||
meshcentralId: { not: null, notIn: meshIds },
|
||||
estado: 'ONLINE',
|
||||
},
|
||||
data: {
|
||||
estado: 'OFFLINE',
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Sincronizacion con MeshCentral completada')
|
||||
} catch (error) {
|
||||
console.error('Error en sincronizacion con MeshCentral:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function crearAlertaDesconexion(dispositivoId: string, clienteId: string) {
|
||||
const dispositivo = await prisma.dispositivo.findUnique({
|
||||
where: { id: dispositivoId },
|
||||
select: { nombre: true },
|
||||
})
|
||||
|
||||
const alerta = await prisma.alerta.create({
|
||||
data: {
|
||||
clienteId,
|
||||
dispositivoId,
|
||||
severidad: 'WARNING',
|
||||
titulo: 'Dispositivo desconectado',
|
||||
mensaje: `El dispositivo ${dispositivo?.nombre || dispositivoId} se ha desconectado`,
|
||||
origen: 'meshcentral',
|
||||
},
|
||||
})
|
||||
|
||||
// Programar procesamiento de notificacion
|
||||
await addAlertJob({
|
||||
type: 'process-alert',
|
||||
alertaId: alerta.id,
|
||||
})
|
||||
}
|
||||
151
src/server/jobs/worker.ts
Normal file
151
src/server/jobs/worker.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Worker } from 'bullmq'
|
||||
import {
|
||||
connection,
|
||||
SyncJobData,
|
||||
AlertJobData,
|
||||
NotificationJobData,
|
||||
MaintenanceJobData,
|
||||
scheduleRecurringJobs,
|
||||
} from './queue'
|
||||
import { syncMeshCentral } from './sync-meshcentral.job'
|
||||
import { syncLibreNMS } from './sync-librenms.job'
|
||||
import { syncHeadwind } from './sync-headwind.job'
|
||||
import { processAlert, checkAlertRules } from './alert-processor.job'
|
||||
import { sendEmail, sendSMS, sendWebhook } from './notification.job'
|
||||
import { cleanupMetrics, aggregateMetrics, healthCheck } from './maintenance.job'
|
||||
|
||||
// Worker de sincronizacion
|
||||
const syncWorker = new Worker<SyncJobData>(
|
||||
'sync',
|
||||
async (job) => {
|
||||
console.log(`Ejecutando trabajo de sincronizacion: ${job.data.type}`)
|
||||
|
||||
switch (job.data.type) {
|
||||
case 'sync-meshcentral':
|
||||
await syncMeshCentral(job.data.clienteId)
|
||||
break
|
||||
case 'sync-librenms':
|
||||
await syncLibreNMS(job.data.clienteId)
|
||||
break
|
||||
case 'sync-headwind':
|
||||
await syncHeadwind(job.data.clienteId)
|
||||
break
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 3,
|
||||
}
|
||||
)
|
||||
|
||||
// Worker de alertas
|
||||
const alertWorker = new Worker<AlertJobData>(
|
||||
'alerts',
|
||||
async (job) => {
|
||||
console.log(`Ejecutando trabajo de alertas: ${job.data.type}`)
|
||||
|
||||
switch (job.data.type) {
|
||||
case 'process-alert':
|
||||
if (job.data.alertaId) {
|
||||
await processAlert(job.data.alertaId)
|
||||
}
|
||||
break
|
||||
case 'check-rules':
|
||||
await checkAlertRules()
|
||||
break
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 5,
|
||||
}
|
||||
)
|
||||
|
||||
// Worker de notificaciones
|
||||
const notificationWorker = new Worker<NotificationJobData>(
|
||||
'notifications',
|
||||
async (job) => {
|
||||
console.log(`Enviando notificacion: ${job.data.type} a ${job.data.destinatario}`)
|
||||
|
||||
switch (job.data.type) {
|
||||
case 'send-email':
|
||||
await sendEmail(job.data)
|
||||
break
|
||||
case 'send-sms':
|
||||
await sendSMS(job.data)
|
||||
break
|
||||
case 'send-webhook':
|
||||
await sendWebhook(job.data)
|
||||
break
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 10,
|
||||
}
|
||||
)
|
||||
|
||||
// Worker de mantenimiento
|
||||
const maintenanceWorker = new Worker<MaintenanceJobData>(
|
||||
'maintenance',
|
||||
async (job) => {
|
||||
console.log(`Ejecutando mantenimiento: ${job.data.type}`)
|
||||
|
||||
switch (job.data.type) {
|
||||
case 'cleanup-metrics':
|
||||
await cleanupMetrics(job.data.olderThanDays || 90)
|
||||
break
|
||||
case 'aggregate-metrics':
|
||||
await aggregateMetrics()
|
||||
break
|
||||
case 'health-check':
|
||||
await healthCheck()
|
||||
break
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1,
|
||||
}
|
||||
)
|
||||
|
||||
// Manejadores de eventos
|
||||
const workers = [syncWorker, alertWorker, notificationWorker, maintenanceWorker]
|
||||
|
||||
workers.forEach((worker) => {
|
||||
worker.on('completed', (job) => {
|
||||
console.log(`Trabajo ${job.id} completado`)
|
||||
})
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`Trabajo ${job?.id} fallido:`, err.message)
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
console.error('Error en worker:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// Iniciar workers
|
||||
async function start() {
|
||||
console.log('Iniciando workers de procesamiento...')
|
||||
|
||||
// Programar trabajos recurrentes
|
||||
await scheduleRecurringJobs()
|
||||
|
||||
console.log('Workers iniciados y listos')
|
||||
}
|
||||
|
||||
// Cerrar workers gracefully
|
||||
async function shutdown() {
|
||||
console.log('Cerrando workers...')
|
||||
await Promise.all(workers.map((w) => w.close()))
|
||||
await connection.quit()
|
||||
console.log('Workers cerrados')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
|
||||
start().catch(console.error)
|
||||
307
src/server/services/headwind/client.ts
Normal file
307
src/server/services/headwind/client.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
interface HeadwindConfig {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
interface HeadwindDevice {
|
||||
id: number
|
||||
number: string
|
||||
imei: string
|
||||
phone: string
|
||||
model: string
|
||||
manufacturer: string
|
||||
osVersion: string
|
||||
batteryLevel: number
|
||||
mdmMode: string
|
||||
lastUpdate: number
|
||||
lat?: number
|
||||
lon?: number
|
||||
enrollTime: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface HeadwindApplication {
|
||||
id: number
|
||||
pkg: string
|
||||
name: string
|
||||
version: string
|
||||
versionCode: number
|
||||
type: string
|
||||
url?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface HeadwindConfiguration {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
password?: string
|
||||
backgroundColor?: string
|
||||
iconSize?: string
|
||||
applications: HeadwindApplication[]
|
||||
}
|
||||
|
||||
export class HeadwindClient {
|
||||
private config: HeadwindConfig
|
||||
|
||||
constructor(config?: Partial<HeadwindConfig>) {
|
||||
this.config = {
|
||||
url: config?.url || process.env.HEADWIND_URL || '',
|
||||
token: config?.token || process.env.HEADWIND_TOKEN || '',
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${this.config.url}/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error en Headwind MDM API: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Obtener todos los dispositivos
|
||||
async getDevices(): Promise<HeadwindDevice[]> {
|
||||
const data = await this.request<{ data: HeadwindDevice[] }>('/public/v1/devices')
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Obtener dispositivo por ID
|
||||
async getDevice(deviceId: number): Promise<HeadwindDevice | null> {
|
||||
try {
|
||||
const data = await this.request<HeadwindDevice>(`/public/v1/devices/${deviceId}`)
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener dispositivos por grupo
|
||||
async getDevicesByGroup(groupId: number): Promise<HeadwindDevice[]> {
|
||||
const data = await this.request<{ data: HeadwindDevice[] }>(`/public/v1/groups/${groupId}/devices`)
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Solicitar actualizacion de ubicacion
|
||||
async requestLocation(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/locate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Bloquear dispositivo
|
||||
async lockDevice(deviceId: number, message?: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/lock`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Desbloquear dispositivo
|
||||
async unlockDevice(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/unlock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Hacer sonar dispositivo
|
||||
async ringDevice(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/ring`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar mensaje al dispositivo
|
||||
async sendMessage(deviceId: number, message: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Borrar datos (factory reset)
|
||||
async wipeDevice(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/wipe`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Reiniciar dispositivo
|
||||
async rebootDevice(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/reboot`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener aplicaciones instaladas
|
||||
async getDeviceApps(deviceId: number): Promise<Array<{
|
||||
pkg: string
|
||||
name: string
|
||||
version: string
|
||||
}>> {
|
||||
const data = await this.request<{ data: Array<{
|
||||
pkg: string
|
||||
name: string
|
||||
version: string
|
||||
}> }>(`/public/v1/devices/${deviceId}/applications`)
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Instalar aplicacion
|
||||
async installApp(deviceId: number, packageName: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/install`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pkg: packageName }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Desinstalar aplicacion
|
||||
async removeApp(deviceId: number, packageName: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}/command/uninstall`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pkg: packageName }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener configuraciones disponibles
|
||||
async getConfigurations(): Promise<HeadwindConfiguration[]> {
|
||||
const data = await this.request<{ data: HeadwindConfiguration[] }>('/public/v1/configurations')
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Asignar configuracion a dispositivo
|
||||
async setDeviceConfiguration(deviceId: number, configurationId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configurationId }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener grupos
|
||||
async getGroups(): Promise<Array<{ id: number; name: string }>> {
|
||||
const data = await this.request<{ data: Array<{ id: number; name: string }> }>('/public/v1/groups')
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Asignar dispositivo a grupo
|
||||
async setDeviceGroup(deviceId: number, groupId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ groupId }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar descripcion del dispositivo
|
||||
async updateDeviceDescription(deviceId: number, description: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ description }),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar dispositivo
|
||||
async deleteDevice(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/public/v1/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener historial de ubicacion
|
||||
async getLocationHistory(deviceId: number, from: Date, to: Date): Promise<Array<{
|
||||
lat: number
|
||||
lon: number
|
||||
ts: number
|
||||
}>> {
|
||||
const data = await this.request<{ data: Array<{
|
||||
lat: number
|
||||
lon: number
|
||||
ts: number
|
||||
}> }>(`/public/v1/devices/${deviceId}/locations?from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`)
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Probar conexion
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/public/v1/info')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default HeadwindClient
|
||||
278
src/server/services/librenms/client.ts
Normal file
278
src/server/services/librenms/client.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
interface LibreNMSConfig {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
interface LibreNMSDevice {
|
||||
device_id: number
|
||||
hostname: string
|
||||
sysName: string
|
||||
ip: string
|
||||
status: number
|
||||
os: string
|
||||
version: string
|
||||
hardware: string
|
||||
serial: string
|
||||
uptime: number
|
||||
location: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
interface LibreNMSPort {
|
||||
port_id: number
|
||||
ifIndex: number
|
||||
ifName: string
|
||||
ifAlias: string
|
||||
ifDescr: string
|
||||
ifSpeed: number
|
||||
ifOperStatus: string
|
||||
ifAdminStatus: string
|
||||
ifInOctets_rate: number
|
||||
ifOutOctets_rate: number
|
||||
}
|
||||
|
||||
interface LibreNMSAlert {
|
||||
id: number
|
||||
device_id: number
|
||||
rule_id: number
|
||||
severity: string
|
||||
state: number
|
||||
alerted: number
|
||||
open: number
|
||||
timestamp: string
|
||||
info: string
|
||||
}
|
||||
|
||||
export class LibreNMSClient {
|
||||
private config: LibreNMSConfig
|
||||
|
||||
constructor(config?: Partial<LibreNMSConfig>) {
|
||||
this.config = {
|
||||
url: config?.url || process.env.LIBRENMS_URL || '',
|
||||
token: config?.token || process.env.LIBRENMS_TOKEN || '',
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${this.config.url}/api/v0${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Auth-Token': this.config.token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error en LibreNMS API: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
// Obtener todos los dispositivos
|
||||
async getDevices(): Promise<LibreNMSDevice[]> {
|
||||
const data = await this.request<{ devices: LibreNMSDevice[] }>('/devices')
|
||||
return data.devices || []
|
||||
}
|
||||
|
||||
// Obtener dispositivo por ID
|
||||
async getDevice(deviceId: number): Promise<LibreNMSDevice | null> {
|
||||
try {
|
||||
const data = await this.request<{ devices: LibreNMSDevice[] }>(`/devices/${deviceId}`)
|
||||
return data.devices?.[0] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener dispositivos por grupo
|
||||
async getDevicesByGroup(groupId: number): Promise<LibreNMSDevice[]> {
|
||||
const data = await this.request<{ devices: LibreNMSDevice[] }>(`/devicegroups/${groupId}`)
|
||||
return data.devices || []
|
||||
}
|
||||
|
||||
// Obtener puertos de un dispositivo
|
||||
async getDevicePorts(deviceId: number): Promise<LibreNMSPort[]> {
|
||||
const data = await this.request<{ ports: LibreNMSPort[] }>(`/devices/${deviceId}/ports`)
|
||||
return data.ports || []
|
||||
}
|
||||
|
||||
// Obtener estadisticas de un puerto
|
||||
async getPortStats(
|
||||
portId: number,
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<Array<{ timestamp: number; in: number; out: number }>> {
|
||||
const data = await this.request<{
|
||||
graphs: Array<{ timestamp: number; in: number; out: number }>
|
||||
}>(`/ports/${portId}/port_bits?from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`)
|
||||
return data.graphs || []
|
||||
}
|
||||
|
||||
// Obtener alertas activas
|
||||
async getAlerts(): Promise<LibreNMSAlert[]> {
|
||||
const data = await this.request<{ alerts: LibreNMSAlert[] }>('/alerts?state=1')
|
||||
return data.alerts || []
|
||||
}
|
||||
|
||||
// Obtener alertas de un dispositivo
|
||||
async getDeviceAlerts(deviceId: number): Promise<LibreNMSAlert[]> {
|
||||
const data = await this.request<{ alerts: LibreNMSAlert[] }>(`/devices/${deviceId}/alerts`)
|
||||
return data.alerts || []
|
||||
}
|
||||
|
||||
// Obtener historial de alertas
|
||||
async getAlertLog(limit = 100): Promise<LibreNMSAlert[]> {
|
||||
const data = await this.request<{ alerts: LibreNMSAlert[] }>(`/alerts?limit=${limit}`)
|
||||
return data.alerts || []
|
||||
}
|
||||
|
||||
// Obtener grupos de dispositivos
|
||||
async getDeviceGroups(): Promise<Array<{ id: number; name: string; desc: string }>> {
|
||||
const data = await this.request<{ groups: Array<{ id: number; name: string; desc: string }> }>('/devicegroups')
|
||||
return data.groups || []
|
||||
}
|
||||
|
||||
// Obtener enlaces de red (para topologia)
|
||||
async getLinks(): Promise<Array<{
|
||||
id: number
|
||||
local_device_id: number
|
||||
local_port_id: number
|
||||
local_port: string
|
||||
remote_device_id: number
|
||||
remote_port_id: number
|
||||
remote_port: string
|
||||
protocol: string
|
||||
}>> {
|
||||
const data = await this.request<{ links: Array<{
|
||||
id: number
|
||||
local_device_id: number
|
||||
local_port_id: number
|
||||
local_port: string
|
||||
remote_device_id: number
|
||||
remote_port_id: number
|
||||
remote_port: string
|
||||
protocol: string
|
||||
}> }>('/resources/links')
|
||||
return data.links || []
|
||||
}
|
||||
|
||||
// Obtener sensores de un dispositivo
|
||||
async getDeviceSensors(deviceId: number): Promise<Array<{
|
||||
sensor_id: number
|
||||
sensor_class: string
|
||||
sensor_descr: string
|
||||
sensor_current: number
|
||||
sensor_limit: number
|
||||
sensor_limit_warn: number
|
||||
}>> {
|
||||
const data = await this.request<{ sensors: Array<{
|
||||
sensor_id: number
|
||||
sensor_class: string
|
||||
sensor_descr: string
|
||||
sensor_current: number
|
||||
sensor_limit: number
|
||||
sensor_limit_warn: number
|
||||
}> }>(`/devices/${deviceId}/health`)
|
||||
return data.sensors || []
|
||||
}
|
||||
|
||||
// Obtener logs de dispositivo
|
||||
async getDeviceLogs(deviceId: number, limit = 50): Promise<Array<{
|
||||
datetime: string
|
||||
msg: string
|
||||
type: string
|
||||
reference: string
|
||||
}>> {
|
||||
const data = await this.request<{ logs: Array<{
|
||||
datetime: string
|
||||
msg: string
|
||||
type: string
|
||||
reference: string
|
||||
}> }>(`/devices/${deviceId}/logs?limit=${limit}`)
|
||||
return data.logs || []
|
||||
}
|
||||
|
||||
// Reconocer alerta
|
||||
async ackAlert(alertId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/alerts/${alertId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ state: 2 }), // 2 = acknowledged
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar dispositivo
|
||||
async addDevice(hostname: string, snmpConfig: {
|
||||
version: 'v1' | 'v2c' | 'v3'
|
||||
community?: string
|
||||
authlevel?: string
|
||||
authname?: string
|
||||
authpass?: string
|
||||
authalgo?: string
|
||||
cryptopass?: string
|
||||
cryptoalgo?: string
|
||||
}): Promise<{ device_id: number } | null> {
|
||||
try {
|
||||
const data = await this.request<{ device_id: number }>('/devices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
hostname,
|
||||
...snmpConfig,
|
||||
}),
|
||||
})
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar dispositivo
|
||||
async deleteDevice(deviceId: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener grafico de CPU
|
||||
async getDeviceCpuGraph(deviceId: number, from: Date, to: Date): Promise<string> {
|
||||
const url = `${this.config.url}/graph.php?device=${deviceId}&type=device_processor&from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`
|
||||
return url
|
||||
}
|
||||
|
||||
// Obtener grafico de memoria
|
||||
async getDeviceMemoryGraph(deviceId: number, from: Date, to: Date): Promise<string> {
|
||||
const url = `${this.config.url}/graph.php?device=${deviceId}&type=device_mempool&from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`
|
||||
return url
|
||||
}
|
||||
|
||||
// Obtener grafico de almacenamiento
|
||||
async getDeviceStorageGraph(deviceId: number, from: Date, to: Date): Promise<string> {
|
||||
const url = `${this.config.url}/graph.php?device=${deviceId}&type=device_storage&from=${Math.floor(from.getTime() / 1000)}&to=${Math.floor(to.getTime() / 1000)}`
|
||||
return url
|
||||
}
|
||||
|
||||
// Probar conexion
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/system')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LibreNMSClient
|
||||
276
src/server/services/meshcentral/client.ts
Normal file
276
src/server/services/meshcentral/client.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import WebSocket from 'ws'
|
||||
|
||||
interface MeshCentralConfig {
|
||||
url: string
|
||||
user: string
|
||||
pass: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
interface MeshDevice {
|
||||
_id: string
|
||||
name: string
|
||||
host: string
|
||||
agent?: {
|
||||
id: number
|
||||
caps: number
|
||||
}
|
||||
conn: number
|
||||
pwr: number
|
||||
ip?: string
|
||||
osdesc?: string
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
success: boolean
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class MeshCentralClient {
|
||||
private config: MeshCentralConfig
|
||||
private ws: WebSocket | null = null
|
||||
private authToken: string | null = null
|
||||
private messageId = 0
|
||||
|
||||
constructor(config?: Partial<MeshCentralConfig>) {
|
||||
this.config = {
|
||||
url: config?.url || process.env.MESHCENTRAL_URL || '',
|
||||
user: config?.user || process.env.MESHCENTRAL_USER || '',
|
||||
pass: config?.pass || process.env.MESHCENTRAL_PASS || '',
|
||||
domain: config?.domain || process.env.MESHCENTRAL_DOMAIN || 'default',
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticate(): Promise<string> {
|
||||
const response = await fetch(`${this.config.url}/api/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'login',
|
||||
username: this.config.user,
|
||||
password: this.config.pass,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error de autenticacion con MeshCentral')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data.token) {
|
||||
throw new Error('No se recibio token de MeshCentral')
|
||||
}
|
||||
|
||||
this.authToken = data.token
|
||||
return data.token
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
if (!this.authToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.url}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'x-meshauth': this.authToken!,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expirado, reautenticar
|
||||
await this.authenticate()
|
||||
return this.request(endpoint, options)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error en MeshCentral API: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Obtener todos los dispositivos
|
||||
async getDevices(): Promise<MeshDevice[]> {
|
||||
const data = await this.request<{ nodes: Record<string, MeshDevice[]> }>('/api/meshes')
|
||||
const devices: MeshDevice[] = []
|
||||
|
||||
for (const meshId in data.nodes) {
|
||||
devices.push(...data.nodes[meshId])
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// Obtener dispositivo por ID
|
||||
async getDevice(nodeId: string): Promise<MeshDevice | null> {
|
||||
const devices = await this.getDevices()
|
||||
return devices.find(d => d._id === nodeId) || null
|
||||
}
|
||||
|
||||
// Obtener grupos (meshes)
|
||||
async getMeshes(): Promise<Array<{ _id: string; name: string; desc: string }>> {
|
||||
const data = await this.request<{ meshes: Array<{ _id: string; name: string; desc: string }> }>('/api/meshes')
|
||||
return data.meshes || []
|
||||
}
|
||||
|
||||
// Obtener dispositivos de un grupo
|
||||
async getMeshDevices(meshId: string): Promise<MeshDevice[]> {
|
||||
const data = await this.request<{ nodes: Record<string, MeshDevice[]> }>(`/api/meshes/${meshId}`)
|
||||
return data.nodes[meshId] || []
|
||||
}
|
||||
|
||||
// Ejecutar comando en dispositivo
|
||||
async runCommand(nodeId: string, command: string, type: 'powershell' | 'cmd' | 'bash' = 'powershell'): Promise<CommandResult> {
|
||||
try {
|
||||
const data = await this.request<{ success: boolean; output?: string; error?: string }>('/api/runcommand', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
nodeids: [nodeId],
|
||||
type,
|
||||
command,
|
||||
runAsUser: 0, // Run as current user
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
success: data.success,
|
||||
output: data.output,
|
||||
error: data.error,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Error desconocido',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accion de energia (restart, shutdown, sleep, wake)
|
||||
async powerAction(nodeId: string, action: 'restart' | 'shutdown' | 'sleep' | 'wake'): Promise<boolean> {
|
||||
const actionMap = {
|
||||
restart: 3,
|
||||
shutdown: 2,
|
||||
sleep: 4,
|
||||
wake: 1,
|
||||
}
|
||||
|
||||
try {
|
||||
await this.request('/api/powersave', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
nodeids: [nodeId],
|
||||
action: actionMap[action],
|
||||
}),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Conectar WebSocket para eventos en tiempo real
|
||||
async connectWebSocket(onMessage: (event: { action: string; data: unknown }) => void): Promise<void> {
|
||||
if (!this.authToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
const wsUrl = this.config.url.replace('https://', 'wss://').replace('http://', 'ws://')
|
||||
|
||||
this.ws = new WebSocket(`${wsUrl}/control.ashx`, {
|
||||
headers: {
|
||||
'x-meshauth': this.authToken!,
|
||||
},
|
||||
})
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WebSocket conectado a MeshCentral')
|
||||
// Autenticar en WebSocket
|
||||
this.ws?.send(
|
||||
JSON.stringify({
|
||||
action: 'authcookie',
|
||||
cookie: this.authToken,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString())
|
||||
onMessage(message)
|
||||
} catch {
|
||||
// Ignorar mensajes no JSON
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error('Error en WebSocket de MeshCentral:', error)
|
||||
})
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log('WebSocket de MeshCentral cerrado')
|
||||
// Reconectar despues de 5 segundos
|
||||
setTimeout(() => this.connectWebSocket(onMessage), 5000)
|
||||
})
|
||||
}
|
||||
|
||||
// Desconectar WebSocket
|
||||
disconnectWebSocket(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener informacion detallada del sistema
|
||||
async getSystemInfo(nodeId: string): Promise<Record<string, unknown>> {
|
||||
const data = await this.request<{ info: Record<string, unknown> }>(`/api/nodes/${nodeId}/info`)
|
||||
return data.info || {}
|
||||
}
|
||||
|
||||
// Obtener procesos en ejecucion
|
||||
async getProcesses(nodeId: string): Promise<Array<{ pid: number; name: string; user: string }>> {
|
||||
const data = await this.request<{ processes: Array<{ pid: number; name: string; user: string }> }>(`/api/nodes/${nodeId}/processes`)
|
||||
return data.processes || []
|
||||
}
|
||||
|
||||
// Obtener servicios (Windows)
|
||||
async getServices(nodeId: string): Promise<Array<{ name: string; displayName: string; status: string }>> {
|
||||
const data = await this.request<{ services: Array<{ name: string; displayName: string; status: string }> }>(`/api/nodes/${nodeId}/services`)
|
||||
return data.services || []
|
||||
}
|
||||
|
||||
// Enviar mensaje al usuario del dispositivo
|
||||
async sendMessage(nodeId: string, title: string, message: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/api/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
nodeids: [nodeId],
|
||||
title,
|
||||
message,
|
||||
}),
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Generar URL de conexion remota
|
||||
getRemoteUrl(nodeId: string, type: 'desktop' | 'terminal' | 'files'): string {
|
||||
const viewModes = {
|
||||
desktop: 11,
|
||||
terminal: 12,
|
||||
files: 13,
|
||||
}
|
||||
return `${this.config.url}/?node=${nodeId}&viewmode=${viewModes[type]}`
|
||||
}
|
||||
}
|
||||
|
||||
export default MeshCentralClient
|
||||
263
src/server/trpc/routers/alertas.router.ts
Normal file
263
src/server/trpc/routers/alertas.router.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
export const alertasRouter = router({
|
||||
// Listar alertas
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
estado: z.enum(['ACTIVA', 'RECONOCIDA', 'RESUELTA']).optional(),
|
||||
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']).optional(),
|
||||
dispositivoId: z.string().optional(),
|
||||
desde: z.date().optional(),
|
||||
hasta: z.date().optional(),
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(50),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, estado, severidad, dispositivoId, desde, hasta, page = 1, limit = 50 } = input || {}
|
||||
|
||||
const where = {
|
||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(estado ? { estado } : {}),
|
||||
...(severidad ? { severidad } : {}),
|
||||
...(dispositivoId ? { dispositivoId } : {}),
|
||||
...(desde || hasta ? {
|
||||
createdAt: {
|
||||
...(desde ? { gte: desde } : {}),
|
||||
...(hasta ? { lte: hasta } : {}),
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [alertas, total] = await Promise.all([
|
||||
ctx.prisma.alerta.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true, codigo: true } },
|
||||
dispositivo: { select: { id: true, nombre: true, tipo: true, ip: true } },
|
||||
},
|
||||
orderBy: [{ severidad: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.alerta.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
alertas,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Obtener alerta por ID
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const alerta = await ctx.prisma.alerta.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
cliente: true,
|
||||
dispositivo: true,
|
||||
regla: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!alerta) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return alerta
|
||||
}),
|
||||
|
||||
// Reconocer alerta
|
||||
reconocer: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const alerta = await ctx.prisma.alerta.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!alerta) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return ctx.prisma.alerta.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
estado: 'RECONOCIDA',
|
||||
reconocidaPor: ctx.user.id,
|
||||
reconocidaEn: new Date(),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Resolver alerta
|
||||
resolver: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const alerta = await ctx.prisma.alerta.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!alerta) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return ctx.prisma.alerta.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
estado: 'RESUELTA',
|
||||
resueltaPor: ctx.user.id,
|
||||
resueltaEn: new Date(),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Reconocer multiples alertas
|
||||
reconocerMultiples: protectedProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const where = {
|
||||
id: { in: input.ids },
|
||||
estado: 'ACTIVA' as const,
|
||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||
}
|
||||
|
||||
return ctx.prisma.alerta.updateMany({
|
||||
where,
|
||||
data: {
|
||||
estado: 'RECONOCIDA',
|
||||
reconocidaPor: ctx.user.id,
|
||||
reconocidaEn: new Date(),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Conteo de alertas activas
|
||||
conteoActivas: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
const where = {
|
||||
estado: 'ACTIVA' as const,
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
}
|
||||
|
||||
const [total, critical, warning, info] = await Promise.all([
|
||||
ctx.prisma.alerta.count({ where }),
|
||||
ctx.prisma.alerta.count({ where: { ...where, severidad: 'CRITICAL' } }),
|
||||
ctx.prisma.alerta.count({ where: { ...where, severidad: 'WARNING' } }),
|
||||
ctx.prisma.alerta.count({ where: { ...where, severidad: 'INFO' } }),
|
||||
])
|
||||
|
||||
return { total, critical, warning, info }
|
||||
}),
|
||||
|
||||
// ==================== REGLAS ====================
|
||||
reglas: router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
return ctx.prisma.alertaRegla.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ clienteId: null }, // Reglas globales
|
||||
...(clienteId ? [{ clienteId }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: [{ clienteId: 'asc' }, { nombre: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const regla = await ctx.prisma.alertaRegla.findUnique({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
if (!regla) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Regla no encontrada' })
|
||||
}
|
||||
|
||||
return regla
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
nombre: z.string(),
|
||||
descripcion: z.string().optional(),
|
||||
tipoDispositivo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
|
||||
metrica: z.string(),
|
||||
operador: z.enum(['>', '<', '>=', '<=', '==']),
|
||||
umbral: z.number(),
|
||||
duracionMinutos: z.number().default(5),
|
||||
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']),
|
||||
notificarEmail: z.boolean().default(true),
|
||||
notificarSms: z.boolean().default(false),
|
||||
notificarWebhook: z.boolean().default(false),
|
||||
webhookUrl: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.alertaRegla.create({ data: input })
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nombre: z.string().optional(),
|
||||
descripcion: z.string().optional().nullable(),
|
||||
activa: z.boolean().optional(),
|
||||
tipoDispositivo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional().nullable(),
|
||||
metrica: z.string().optional(),
|
||||
operador: z.enum(['>', '<', '>=', '<=', '==']).optional(),
|
||||
umbral: z.number().optional(),
|
||||
duracionMinutos: z.number().optional(),
|
||||
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']).optional(),
|
||||
notificarEmail: z.boolean().optional(),
|
||||
notificarSms: z.boolean().optional(),
|
||||
notificarWebhook: z.boolean().optional(),
|
||||
webhookUrl: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
return ctx.prisma.alertaRegla.update({ where: { id }, data })
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.alertaRegla.delete({ where: { id: input.id } })
|
||||
}),
|
||||
}),
|
||||
})
|
||||
173
src/server/trpc/routers/auth.router.ts
Normal file
173
src/server/trpc/routers/auth.router.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||
import { createSession, validateMeshCentralUser, setSessionCookie, clearSession } from '@/lib/auth'
|
||||
|
||||
export const authRouter = router({
|
||||
// Login con email/password
|
||||
login: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const usuario = await ctx.prisma.usuario.findUnique({
|
||||
where: { email: input.email },
|
||||
})
|
||||
|
||||
if (!usuario || !usuario.passwordHash) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Credenciales invalidas',
|
||||
})
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(input.password, usuario.passwordHash)
|
||||
if (!validPassword) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Credenciales invalidas',
|
||||
})
|
||||
}
|
||||
|
||||
if (!usuario.activo) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Usuario desactivado',
|
||||
})
|
||||
}
|
||||
|
||||
// Actualizar lastLogin
|
||||
await ctx.prisma.usuario.update({
|
||||
where: { id: usuario.id },
|
||||
data: { lastLogin: new Date() },
|
||||
})
|
||||
|
||||
const token = await createSession({
|
||||
id: usuario.id,
|
||||
email: usuario.email,
|
||||
nombre: usuario.nombre,
|
||||
rol: usuario.rol,
|
||||
clienteId: usuario.clienteId,
|
||||
meshcentralUser: usuario.meshcentralUser,
|
||||
})
|
||||
|
||||
await setSessionCookie(token)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: usuario.id,
|
||||
email: usuario.email,
|
||||
nombre: usuario.nombre,
|
||||
rol: usuario.rol,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Login con MeshCentral SSO
|
||||
loginMeshCentral: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const user = await validateMeshCentralUser(input.username, input.token)
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Token de MeshCentral invalido',
|
||||
})
|
||||
}
|
||||
|
||||
const sessionToken = await createSession(user)
|
||||
await setSessionCookie(sessionToken)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
rol: user.rol,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Logout
|
||||
logout: protectedProcedure.mutation(async () => {
|
||||
await clearSession()
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Obtener usuario actual
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const usuario = await ctx.prisma.usuario.findUnique({
|
||||
where: { id: ctx.user.id },
|
||||
include: {
|
||||
cliente: true,
|
||||
permisos: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!usuario) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Usuario no encontrado',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: usuario.id,
|
||||
email: usuario.email,
|
||||
nombre: usuario.nombre,
|
||||
rol: usuario.rol,
|
||||
avatar: usuario.avatar,
|
||||
cliente: usuario.cliente,
|
||||
permisos: usuario.permisos,
|
||||
}
|
||||
}),
|
||||
|
||||
// Cambiar password
|
||||
changePassword: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(8),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const usuario = await ctx.prisma.usuario.findUnique({
|
||||
where: { id: ctx.user.id },
|
||||
})
|
||||
|
||||
if (!usuario || !usuario.passwordHash) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No se puede cambiar password para usuarios SSO',
|
||||
})
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(input.currentPassword, usuario.passwordHash)
|
||||
if (!validPassword) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Password actual incorrecto',
|
||||
})
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(input.newPassword, 12)
|
||||
await ctx.prisma.usuario.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: { passwordHash: newHash },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
382
src/server/trpc/routers/celulares.router.ts
Normal file
382
src/server/trpc/routers/celulares.router.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { HeadwindClient } from '@/server/services/headwind/client'
|
||||
|
||||
export const celularesRouter = router({
|
||||
// Listar celulares y tablets
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, estado, search, page = 1, limit = 20 } = input || {}
|
||||
|
||||
const where = {
|
||||
tipo: { in: ['CELULAR', 'TABLET'] as const },
|
||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(estado ? { estado } : {}),
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
||||
{ imei: { contains: search } },
|
||||
{ numeroTelefono: { contains: search } },
|
||||
],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [dispositivos, total] = await Promise.all([
|
||||
ctx.prisma.dispositivo.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true, codigo: true } },
|
||||
ubicacion: { select: { id: true, nombre: true } },
|
||||
},
|
||||
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.dispositivo.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
dispositivos,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Obtener celular por ID
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
cliente: true,
|
||||
ubicacion: true,
|
||||
alertas: {
|
||||
where: { estado: 'ACTIVA' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!dispositivo) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return dispositivo
|
||||
}),
|
||||
|
||||
// Obtener ubicacion actual
|
||||
ubicacion: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
latitud: true,
|
||||
longitud: true,
|
||||
gpsUpdatedAt: true,
|
||||
clienteId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!dispositivo) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return {
|
||||
lat: dispositivo.latitud,
|
||||
lng: dispositivo.longitud,
|
||||
updatedAt: dispositivo.gpsUpdatedAt,
|
||||
}
|
||||
}),
|
||||
|
||||
// Solicitar actualizacion de ubicacion
|
||||
solicitarUbicacion: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.requestLocation(dispositivo.headwindId)
|
||||
|
||||
return { success: true, message: 'Solicitud de ubicacion enviada' }
|
||||
}),
|
||||
|
||||
// Bloquear dispositivo
|
||||
bloquear: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
mensaje: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.lockDevice(dispositivo.headwindId, input.mensaje)
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'bloquear',
|
||||
recurso: 'celular',
|
||||
detalles: { mensaje: input.mensaje },
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Desbloquear dispositivo
|
||||
desbloquear: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.unlockDevice(dispositivo.headwindId)
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'desbloquear',
|
||||
recurso: 'celular',
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Hacer sonar dispositivo
|
||||
sonar: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.ringDevice(dispositivo.headwindId)
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Enviar mensaje al dispositivo
|
||||
enviarMensaje: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
mensaje: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.sendMessage(dispositivo.headwindId, input.mensaje)
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Borrar datos (factory reset)
|
||||
borrarDatos: adminProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.wipeDevice(dispositivo.headwindId)
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'borrar_datos',
|
||||
recurso: 'celular',
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Instalar aplicacion
|
||||
instalarApp: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
packageName: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.installApp(dispositivo.headwindId, input.packageName)
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'instalar_app',
|
||||
recurso: 'celular',
|
||||
detalles: { packageName: input.packageName },
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Desinstalar aplicacion
|
||||
desinstalarApp: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
packageName: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.headwindId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene Headwind MDM',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const headwindClient = new HeadwindClient()
|
||||
await headwindClient.removeApp(dispositivo.headwindId, input.packageName)
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'desinstalar_app',
|
||||
recurso: 'celular',
|
||||
detalles: { packageName: input.packageName },
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
264
src/server/trpc/routers/clientes.router.ts
Normal file
264
src/server/trpc/routers/clientes.router.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
export const clientesRouter = router({
|
||||
// Listar clientes
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
activo: z.boolean().optional(),
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { search, activo, page = 1, limit = 20 } = input || {}
|
||||
|
||||
// Si el usuario tiene clienteId, solo puede ver su cliente
|
||||
const where = {
|
||||
...(ctx.user.clienteId ? { id: ctx.user.clienteId } : {}),
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
||||
{ codigo: { contains: search, mode: 'insensitive' as const } },
|
||||
],
|
||||
} : {}),
|
||||
...(activo !== undefined ? { activo } : {}),
|
||||
}
|
||||
|
||||
const [clientes, total] = await Promise.all([
|
||||
ctx.prisma.cliente.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: {
|
||||
select: { dispositivos: true, usuarios: true },
|
||||
},
|
||||
},
|
||||
orderBy: { nombre: 'asc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.cliente.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
clientes,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Obtener cliente por ID
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verificar acceso
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== input.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const cliente = await ctx.prisma.cliente.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
ubicaciones: true,
|
||||
_count: {
|
||||
select: {
|
||||
dispositivos: true,
|
||||
usuarios: true,
|
||||
alertas: { where: { estado: 'ACTIVA' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!cliente) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Cliente no encontrado' })
|
||||
}
|
||||
|
||||
return cliente
|
||||
}),
|
||||
|
||||
// Crear cliente
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
nombre: z.string().min(1),
|
||||
codigo: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
telefono: z.string().optional(),
|
||||
notas: z.string().optional(),
|
||||
meshcentralGrupo: z.string().optional(),
|
||||
librenmsGrupo: z.number().optional(),
|
||||
headwindGrupo: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verificar codigo unico
|
||||
const existe = await ctx.prisma.cliente.findUnique({
|
||||
where: { codigo: input.codigo },
|
||||
})
|
||||
if (existe) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Ya existe un cliente con ese codigo',
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.prisma.cliente.create({
|
||||
data: input,
|
||||
})
|
||||
}),
|
||||
|
||||
// Actualizar cliente
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nombre: z.string().min(1).optional(),
|
||||
email: z.string().email().optional().nullable(),
|
||||
telefono: z.string().optional().nullable(),
|
||||
activo: z.boolean().optional(),
|
||||
notas: z.string().optional().nullable(),
|
||||
meshcentralGrupo: z.string().optional().nullable(),
|
||||
librenmsGrupo: z.number().optional().nullable(),
|
||||
headwindGrupo: z.number().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
return ctx.prisma.cliente.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}),
|
||||
|
||||
// Eliminar cliente
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.cliente.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
}),
|
||||
|
||||
// Estadisticas del dashboard por cliente
|
||||
dashboardStats: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
const where = clienteId ? { clienteId } : {}
|
||||
|
||||
const [
|
||||
totalDispositivos,
|
||||
dispositivosOnline,
|
||||
dispositivosOffline,
|
||||
dispositivosAlerta,
|
||||
alertasActivas,
|
||||
alertasCriticas,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.dispositivo.count({ where }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'OFFLINE' } }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ALERTA' } }),
|
||||
ctx.prisma.alerta.count({
|
||||
where: { ...where, estado: 'ACTIVA' },
|
||||
}),
|
||||
ctx.prisma.alerta.count({
|
||||
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
totalDispositivos,
|
||||
dispositivosOnline,
|
||||
dispositivosOffline,
|
||||
dispositivosAlerta,
|
||||
alertasActivas,
|
||||
alertasCriticas,
|
||||
sesionesActivas: 0, // TODO: implementar
|
||||
}
|
||||
}),
|
||||
|
||||
// Ubicaciones
|
||||
ubicaciones: router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.clienteUbicacion.findMany({
|
||||
where: { clienteId: input.clienteId },
|
||||
orderBy: [{ principal: 'desc' }, { nombre: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string(),
|
||||
nombre: z.string(),
|
||||
direccion: z.string().optional(),
|
||||
ciudad: z.string().optional(),
|
||||
estado: z.string().optional(),
|
||||
pais: z.string().default('Mexico'),
|
||||
codigoPostal: z.string().optional(),
|
||||
latitud: z.number().optional(),
|
||||
longitud: z.number().optional(),
|
||||
principal: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Si es principal, quitar principal a las demas
|
||||
if (input.principal) {
|
||||
await ctx.prisma.clienteUbicacion.updateMany({
|
||||
where: { clienteId: input.clienteId },
|
||||
data: { principal: false },
|
||||
})
|
||||
}
|
||||
return ctx.prisma.clienteUbicacion.create({ data: input })
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nombre: z.string().optional(),
|
||||
direccion: z.string().optional().nullable(),
|
||||
ciudad: z.string().optional().nullable(),
|
||||
estado: z.string().optional().nullable(),
|
||||
pais: z.string().optional(),
|
||||
codigoPostal: z.string().optional().nullable(),
|
||||
latitud: z.number().optional().nullable(),
|
||||
longitud: z.number().optional().nullable(),
|
||||
principal: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
// Si es principal, quitar principal a las demas
|
||||
if (data.principal) {
|
||||
const ubicacion = await ctx.prisma.clienteUbicacion.findUnique({ where: { id } })
|
||||
if (ubicacion) {
|
||||
await ctx.prisma.clienteUbicacion.updateMany({
|
||||
where: { clienteId: ubicacion.clienteId, id: { not: id } },
|
||||
data: { principal: false },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.prisma.clienteUbicacion.update({ where: { id }, data })
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.clienteUbicacion.delete({ where: { id: input.id } })
|
||||
}),
|
||||
}),
|
||||
})
|
||||
370
src/server/trpc/routers/configuracion.router.ts
Normal file
370
src/server/trpc/routers/configuracion.router.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, superAdminProcedure } from '../trpc'
|
||||
|
||||
export const configuracionRouter = router({
|
||||
// Obtener todas las configuraciones
|
||||
list: adminProcedure
|
||||
.input(z.object({ categoria: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input?.categoria ? { categoria: input.categoria } : {}
|
||||
|
||||
const configuraciones = await ctx.prisma.configuracion.findMany({
|
||||
where,
|
||||
orderBy: [{ categoria: 'asc' }, { clave: 'asc' }],
|
||||
})
|
||||
|
||||
// Ocultar valores sensibles si no es super admin
|
||||
if (ctx.user.rol !== 'SUPER_ADMIN') {
|
||||
return configuraciones.map(c => ({
|
||||
...c,
|
||||
valor: c.clave.includes('password') || c.clave.includes('token') || c.clave.includes('secret')
|
||||
? '********'
|
||||
: c.valor,
|
||||
}))
|
||||
}
|
||||
|
||||
return configuraciones
|
||||
}),
|
||||
|
||||
// Obtener una configuracion por clave
|
||||
get: adminProcedure
|
||||
.input(z.object({ clave: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const config = await ctx.prisma.configuracion.findUnique({
|
||||
where: { clave: input.clave },
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Ocultar valores sensibles
|
||||
if (
|
||||
ctx.user.rol !== 'SUPER_ADMIN' &&
|
||||
(input.clave.includes('password') ||
|
||||
input.clave.includes('token') ||
|
||||
input.clave.includes('secret'))
|
||||
) {
|
||||
return { ...config, valor: '********' }
|
||||
}
|
||||
|
||||
return config
|
||||
}),
|
||||
|
||||
// Establecer configuracion
|
||||
set: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clave: z.string(),
|
||||
valor: z.any(),
|
||||
tipo: z.enum(['string', 'number', 'boolean', 'json']).default('string'),
|
||||
categoria: z.string().default('general'),
|
||||
descripcion: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.configuracion.upsert({
|
||||
where: { clave: input.clave },
|
||||
update: {
|
||||
valor: input.valor,
|
||||
tipo: input.tipo,
|
||||
descripcion: input.descripcion,
|
||||
},
|
||||
create: {
|
||||
clave: input.clave,
|
||||
valor: input.valor,
|
||||
tipo: input.tipo,
|
||||
categoria: input.categoria,
|
||||
descripcion: input.descripcion,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Eliminar configuracion
|
||||
delete: superAdminProcedure
|
||||
.input(z.object({ clave: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.configuracion.delete({
|
||||
where: { clave: input.clave },
|
||||
})
|
||||
}),
|
||||
|
||||
// Configuraciones de integracion
|
||||
integraciones: router({
|
||||
// Obtener estado de integraciones
|
||||
status: adminProcedure.query(async ({ ctx }) => {
|
||||
const [meshcentral, librenms, headwind] = await Promise.all([
|
||||
ctx.prisma.configuracion.findFirst({
|
||||
where: { clave: 'meshcentral_url' },
|
||||
}),
|
||||
ctx.prisma.configuracion.findFirst({
|
||||
where: { clave: 'librenms_url' },
|
||||
}),
|
||||
ctx.prisma.configuracion.findFirst({
|
||||
where: { clave: 'headwind_url' },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
meshcentral: {
|
||||
configurado: !!meshcentral,
|
||||
url: meshcentral?.valor as string | undefined,
|
||||
},
|
||||
librenms: {
|
||||
configurado: !!librenms,
|
||||
url: librenms?.valor as string | undefined,
|
||||
},
|
||||
headwind: {
|
||||
configurado: !!headwind,
|
||||
url: headwind?.valor as string | undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Configurar MeshCentral
|
||||
setMeshCentral: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
domain: z.string().default('default'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all([
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'meshcentral_url' },
|
||||
update: { valor: input.url },
|
||||
create: {
|
||||
clave: 'meshcentral_url',
|
||||
valor: input.url,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'URL de MeshCentral',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'meshcentral_user' },
|
||||
update: { valor: input.user },
|
||||
create: {
|
||||
clave: 'meshcentral_user',
|
||||
valor: input.user,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'Usuario de MeshCentral',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'meshcentral_password' },
|
||||
update: { valor: input.password },
|
||||
create: {
|
||||
clave: 'meshcentral_password',
|
||||
valor: input.password,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'Password de MeshCentral',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'meshcentral_domain' },
|
||||
update: { valor: input.domain },
|
||||
create: {
|
||||
clave: 'meshcentral_domain',
|
||||
valor: input.domain,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'Dominio de MeshCentral',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Configurar LibreNMS
|
||||
setLibreNMS: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
token: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all([
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'librenms_url' },
|
||||
update: { valor: input.url },
|
||||
create: {
|
||||
clave: 'librenms_url',
|
||||
valor: input.url,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'URL de LibreNMS',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'librenms_token' },
|
||||
update: { valor: input.token },
|
||||
create: {
|
||||
clave: 'librenms_token',
|
||||
valor: input.token,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'Token de API de LibreNMS',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Configurar Headwind MDM
|
||||
setHeadwind: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
token: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all([
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'headwind_url' },
|
||||
update: { valor: input.url },
|
||||
create: {
|
||||
clave: 'headwind_url',
|
||||
valor: input.url,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'URL de Headwind MDM',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'headwind_token' },
|
||||
update: { valor: input.token },
|
||||
create: {
|
||||
clave: 'headwind_token',
|
||||
valor: input.token,
|
||||
tipo: 'string',
|
||||
categoria: 'integracion',
|
||||
descripcion: 'Token de API de Headwind MDM',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Probar conexion
|
||||
test: superAdminProcedure
|
||||
.input(z.object({ integracion: z.enum(['meshcentral', 'librenms', 'headwind']) }))
|
||||
.mutation(async ({ input }) => {
|
||||
// TODO: Implementar prueba de conexion real
|
||||
return {
|
||||
success: true,
|
||||
message: `Conexion a ${input.integracion} exitosa`,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
|
||||
// Configuraciones de notificaciones
|
||||
notificaciones: router({
|
||||
// Obtener configuracion de SMTP
|
||||
getSmtp: adminProcedure.query(async ({ ctx }) => {
|
||||
const [host, port, user, from] = await Promise.all([
|
||||
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_host' } }),
|
||||
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_port' } }),
|
||||
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_user' } }),
|
||||
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_from' } }),
|
||||
])
|
||||
|
||||
return {
|
||||
host: host?.valor as string | undefined,
|
||||
port: port?.valor as number | undefined,
|
||||
user: user?.valor as string | undefined,
|
||||
from: from?.valor as string | undefined,
|
||||
}
|
||||
}),
|
||||
|
||||
// Configurar SMTP
|
||||
setSmtp: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
from: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all([
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'smtp_host' },
|
||||
update: { valor: input.host },
|
||||
create: {
|
||||
clave: 'smtp_host',
|
||||
valor: input.host,
|
||||
tipo: 'string',
|
||||
categoria: 'notificacion',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'smtp_port' },
|
||||
update: { valor: input.port },
|
||||
create: {
|
||||
clave: 'smtp_port',
|
||||
valor: input.port,
|
||||
tipo: 'number',
|
||||
categoria: 'notificacion',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'smtp_user' },
|
||||
update: { valor: input.user },
|
||||
create: {
|
||||
clave: 'smtp_user',
|
||||
valor: input.user,
|
||||
tipo: 'string',
|
||||
categoria: 'notificacion',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'smtp_password' },
|
||||
update: { valor: input.password },
|
||||
create: {
|
||||
clave: 'smtp_password',
|
||||
valor: input.password,
|
||||
tipo: 'string',
|
||||
categoria: 'notificacion',
|
||||
},
|
||||
}),
|
||||
ctx.prisma.configuracion.upsert({
|
||||
where: { clave: 'smtp_from' },
|
||||
update: { valor: input.from },
|
||||
create: {
|
||||
clave: 'smtp_from',
|
||||
valor: input.from,
|
||||
tipo: 'string',
|
||||
categoria: 'notificacion',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Probar email
|
||||
testEmail: superAdminProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ input }) => {
|
||||
// TODO: Implementar envio de email de prueba
|
||||
return {
|
||||
success: true,
|
||||
message: `Email de prueba enviado a ${input.email}`,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
})
|
||||
333
src/server/trpc/routers/equipos.router.ts
Normal file
333
src/server/trpc/routers/equipos.router.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { MeshCentralClient } from '@/server/services/meshcentral/client'
|
||||
|
||||
export const equiposRouter = router({
|
||||
// Listar equipos de computo (PC, laptop, servidor)
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR']).optional(),
|
||||
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
|
||||
|
||||
const where = {
|
||||
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as const },
|
||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(estado ? { estado } : {}),
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
||||
{ ip: { contains: search } },
|
||||
{ serial: { contains: search, mode: 'insensitive' as const } },
|
||||
],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [dispositivos, total] = await Promise.all([
|
||||
ctx.prisma.dispositivo.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true, codigo: true } },
|
||||
ubicacion: { select: { id: true, nombre: true } },
|
||||
},
|
||||
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.dispositivo.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
dispositivos,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Obtener equipo por ID
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
cliente: true,
|
||||
ubicacion: true,
|
||||
software: {
|
||||
orderBy: { nombre: 'asc' },
|
||||
take: 100,
|
||||
},
|
||||
alertas: {
|
||||
where: { estado: 'ACTIVA' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!dispositivo) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
|
||||
}
|
||||
|
||||
// Verificar acceso
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return dispositivo
|
||||
}),
|
||||
|
||||
// Obtener metricas historicas
|
||||
metricas: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
periodo: z.enum(['1h', '6h', '24h', '7d', '30d']).default('24h'),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date()
|
||||
let desde: Date
|
||||
|
||||
switch (input.periodo) {
|
||||
case '1h':
|
||||
desde = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
break
|
||||
case '6h':
|
||||
desde = new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
||||
break
|
||||
case '24h':
|
||||
desde = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '7d':
|
||||
desde = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '30d':
|
||||
desde = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
|
||||
// Para periodos cortos usar metricas detalladas, para largos usar hourly
|
||||
if (['1h', '6h', '24h'].includes(input.periodo)) {
|
||||
return ctx.prisma.dispositivoMetrica.findMany({
|
||||
where: {
|
||||
dispositivoId: input.dispositivoId,
|
||||
timestamp: { gte: desde },
|
||||
},
|
||||
orderBy: { timestamp: 'asc' },
|
||||
})
|
||||
} else {
|
||||
return ctx.prisma.dispositivoMetricaHourly.findMany({
|
||||
where: {
|
||||
dispositivoId: input.dispositivoId,
|
||||
hora: { gte: desde },
|
||||
},
|
||||
orderBy: { hora: 'asc' },
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// Iniciar sesion remota
|
||||
iniciarSesion: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
tipo: z.enum(['desktop', 'terminal', 'files']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.meshcentralId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene agente MeshCentral',
|
||||
})
|
||||
}
|
||||
|
||||
// Verificar acceso
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
// Crear registro de sesion
|
||||
const sesion = await ctx.prisma.sesionRemota.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
tipo: input.tipo,
|
||||
},
|
||||
})
|
||||
|
||||
// Generar URL de MeshCentral para la sesion
|
||||
const meshUrl = process.env.MESHCENTRAL_URL
|
||||
const sessionUrl = `${meshUrl}/?node=${dispositivo.meshcentralId}&viewmode=${
|
||||
input.tipo === 'desktop' ? '11' : input.tipo === 'terminal' ? '12' : '13'
|
||||
}`
|
||||
|
||||
return {
|
||||
sesionId: sesion.id,
|
||||
url: sessionUrl,
|
||||
}
|
||||
}),
|
||||
|
||||
// Finalizar sesion remota
|
||||
finalizarSesion: protectedProcedure
|
||||
.input(z.object({ sesionId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sesion = await ctx.prisma.sesionRemota.findUnique({
|
||||
where: { id: input.sesionId },
|
||||
})
|
||||
|
||||
if (!sesion || sesion.usuarioId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Sesion no encontrada' })
|
||||
}
|
||||
|
||||
const ahora = new Date()
|
||||
const duracion = Math.floor((ahora.getTime() - sesion.iniciadaEn.getTime()) / 1000)
|
||||
|
||||
return ctx.prisma.sesionRemota.update({
|
||||
where: { id: input.sesionId },
|
||||
data: {
|
||||
finalizadaEn: ahora,
|
||||
duracion,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Ejecutar comando en equipo
|
||||
ejecutarComando: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
comando: z.string(),
|
||||
tipo: z.enum(['powershell', 'cmd', 'bash']).default('powershell'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.meshcentralId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene agente MeshCentral',
|
||||
})
|
||||
}
|
||||
|
||||
// Verificar acceso
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
// Ejecutar comando via MeshCentral
|
||||
const meshClient = new MeshCentralClient()
|
||||
const resultado = await meshClient.runCommand(
|
||||
dispositivo.meshcentralId,
|
||||
input.comando,
|
||||
input.tipo
|
||||
)
|
||||
|
||||
// Registrar en audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'ejecutar_comando',
|
||||
recurso: 'dispositivo',
|
||||
detalles: {
|
||||
comando: input.comando,
|
||||
tipo: input.tipo,
|
||||
exito: resultado.success,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return resultado
|
||||
}),
|
||||
|
||||
// Reiniciar equipo
|
||||
reiniciar: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.meshcentralId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene agente MeshCentral',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const meshClient = new MeshCentralClient()
|
||||
await meshClient.powerAction(dispositivo.meshcentralId, 'restart')
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'reiniciar',
|
||||
recurso: 'dispositivo',
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Apagar equipo
|
||||
apagar: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.meshcentralId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene agente MeshCentral',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const meshClient = new MeshCentralClient()
|
||||
await meshClient.powerAction(dispositivo.meshcentralId, 'shutdown')
|
||||
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
usuarioId: ctx.user.id,
|
||||
dispositivoId: input.dispositivoId,
|
||||
accion: 'apagar',
|
||||
recurso: 'dispositivo',
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
24
src/server/trpc/routers/index.ts
Normal file
24
src/server/trpc/routers/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { router } from '../trpc'
|
||||
import { authRouter } from './auth.router'
|
||||
import { clientesRouter } from './clientes.router'
|
||||
import { equiposRouter } from './equipos.router'
|
||||
import { celularesRouter } from './celulares.router'
|
||||
import { redRouter } from './red.router'
|
||||
import { alertasRouter } from './alertas.router'
|
||||
import { reportesRouter } from './reportes.router'
|
||||
import { usuariosRouter } from './usuarios.router'
|
||||
import { configuracionRouter } from './configuracion.router'
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
clientes: clientesRouter,
|
||||
equipos: equiposRouter,
|
||||
celulares: celularesRouter,
|
||||
red: redRouter,
|
||||
alertas: alertasRouter,
|
||||
reportes: reportesRouter,
|
||||
usuarios: usuariosRouter,
|
||||
configuracion: configuracionRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
305
src/server/trpc/routers/red.router.ts
Normal file
305
src/server/trpc/routers/red.router.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
import { LibreNMSClient } from '@/server/services/librenms/client'
|
||||
|
||||
export const redRouter = router({
|
||||
// Listar dispositivos de red
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
tipo: z.enum(['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
|
||||
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
|
||||
|
||||
const tiposRed = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const
|
||||
|
||||
const where = {
|
||||
tipo: tipo ? { equals: tipo } : { in: tiposRed },
|
||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(estado ? { estado } : {}),
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
||||
{ ip: { contains: search } },
|
||||
{ mac: { contains: search, mode: 'insensitive' as const } },
|
||||
],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [dispositivos, total] = await Promise.all([
|
||||
ctx.prisma.dispositivo.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true, codigo: true } },
|
||||
ubicacion: { select: { id: true, nombre: true } },
|
||||
},
|
||||
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.dispositivo.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
dispositivos,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Obtener dispositivo de red por ID
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
cliente: true,
|
||||
ubicacion: true,
|
||||
alertas: {
|
||||
where: { estado: 'ACTIVA' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!dispositivo) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return dispositivo
|
||||
}),
|
||||
|
||||
// Obtener interfaces de un dispositivo
|
||||
interfaces: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.librenmsId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene LibreNMS',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const librenms = new LibreNMSClient()
|
||||
return librenms.getDevicePorts(dispositivo.librenmsId)
|
||||
}),
|
||||
|
||||
// Obtener grafico de trafico de una interfaz
|
||||
trafico: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
portId: z.number(),
|
||||
periodo: z.enum(['1h', '6h', '24h', '7d', '30d']).default('24h'),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.librenmsId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene LibreNMS',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const librenms = new LibreNMSClient()
|
||||
|
||||
// Calcular rango de tiempo
|
||||
const now = new Date()
|
||||
let desde: Date
|
||||
|
||||
switch (input.periodo) {
|
||||
case '1h':
|
||||
desde = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
break
|
||||
case '6h':
|
||||
desde = new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
||||
break
|
||||
case '24h':
|
||||
desde = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '7d':
|
||||
desde = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '30d':
|
||||
desde = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
|
||||
return librenms.getPortStats(input.portId, desde, now)
|
||||
}),
|
||||
|
||||
// Obtener topologia de red
|
||||
topologia: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
// Obtener dispositivos de red del cliente
|
||||
const dispositivos = await ctx.prisma.dispositivo.findMany({
|
||||
where: {
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP'] },
|
||||
librenmsId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
ip: true,
|
||||
tipo: true,
|
||||
estado: true,
|
||||
librenmsId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (dispositivos.length === 0) {
|
||||
return { nodes: [], links: [] }
|
||||
}
|
||||
|
||||
// Obtener enlaces de LibreNMS
|
||||
const librenms = new LibreNMSClient()
|
||||
const links = await librenms.getLinks()
|
||||
|
||||
// Mapear a nodos y enlaces para visualizacion
|
||||
const librenmsIdToDevice = new Map(
|
||||
dispositivos
|
||||
.filter(d => d.librenmsId !== null)
|
||||
.map(d => [d.librenmsId!, d])
|
||||
)
|
||||
|
||||
const nodes = dispositivos.map(d => ({
|
||||
id: d.id,
|
||||
name: d.nombre,
|
||||
ip: d.ip,
|
||||
type: d.tipo,
|
||||
status: d.estado,
|
||||
}))
|
||||
|
||||
const edges = links
|
||||
.filter(
|
||||
(l: { local_device_id: number; remote_device_id: number }) =>
|
||||
librenmsIdToDevice.has(l.local_device_id) && librenmsIdToDevice.has(l.remote_device_id)
|
||||
)
|
||||
.map((l: { local_device_id: number; remote_device_id: number; local_port: string; remote_port: string }) => ({
|
||||
source: librenmsIdToDevice.get(l.local_device_id)!.id,
|
||||
target: librenmsIdToDevice.get(l.remote_device_id)!.id,
|
||||
localPort: l.local_port,
|
||||
remotePort: l.remote_port,
|
||||
}))
|
||||
|
||||
return { nodes, links: edges }
|
||||
}),
|
||||
|
||||
// Obtener alertas SNMP activas
|
||||
alertasSNMP: protectedProcedure
|
||||
.input(z.object({ dispositivoId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const librenms = new LibreNMSClient()
|
||||
|
||||
if (input.dispositivoId) {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.librenmsId) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return librenms.getDeviceAlerts(dispositivo.librenmsId)
|
||||
}
|
||||
|
||||
return librenms.getAlerts()
|
||||
}),
|
||||
|
||||
// Obtener datos de NetFlow
|
||||
netflow: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dispositivoId: z.string(),
|
||||
periodo: z.enum(['1h', '6h', '24h']).default('1h'),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const dispositivo = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.dispositivoId },
|
||||
})
|
||||
|
||||
if (!dispositivo || !dispositivo.librenmsId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Dispositivo no tiene LibreNMS',
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
// LibreNMS puede tener integracion con nfsen para netflow
|
||||
// Por ahora retornamos datos de ejemplo
|
||||
return {
|
||||
topTalkers: [],
|
||||
topProtocols: [],
|
||||
topPorts: [],
|
||||
}
|
||||
}),
|
||||
|
||||
// Estadisticas de red por cliente
|
||||
stats: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
const where = {
|
||||
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const },
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
}
|
||||
|
||||
const [total, online, offline, alertas] = await Promise.all([
|
||||
ctx.prisma.dispositivo.count({ where }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'OFFLINE' } }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ALERTA' } }),
|
||||
])
|
||||
|
||||
return { total, online, offline, alertas }
|
||||
}),
|
||||
})
|
||||
389
src/server/trpc/routers/reportes.router.ts
Normal file
389
src/server/trpc/routers/reportes.router.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
|
||||
export const reportesRouter = router({
|
||||
// Reporte de inventario
|
||||
inventario: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input?.clienteId
|
||||
|
||||
const where = {
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(input?.tipo ? { tipo: input.tipo } : {}),
|
||||
}
|
||||
|
||||
const dispositivos = await ctx.prisma.dispositivo.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { nombre: true, codigo: true } },
|
||||
ubicacion: { select: { nombre: true } },
|
||||
},
|
||||
orderBy: [{ cliente: { nombre: 'asc' } }, { tipo: 'asc' }, { nombre: 'asc' }],
|
||||
})
|
||||
|
||||
// Resumen por tipo
|
||||
const porTipo = await ctx.prisma.dispositivo.groupBy({
|
||||
by: ['tipo'],
|
||||
where,
|
||||
_count: true,
|
||||
})
|
||||
|
||||
// Resumen por cliente
|
||||
const porCliente = await ctx.prisma.dispositivo.groupBy({
|
||||
by: ['clienteId'],
|
||||
where,
|
||||
_count: true,
|
||||
})
|
||||
|
||||
return {
|
||||
dispositivos,
|
||||
resumen: {
|
||||
total: dispositivos.length,
|
||||
porTipo: porTipo.map(t => ({ tipo: t.tipo, count: t._count })),
|
||||
porCliente: porCliente.length,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Reporte de uptime
|
||||
uptime: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
desde: z.date(),
|
||||
hasta: z.date(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
const where = {
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
}
|
||||
|
||||
// Obtener dispositivos
|
||||
const dispositivos = await ctx.prisma.dispositivo.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
tipo: true,
|
||||
cliente: { select: { nombre: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Calcular uptime basado en metricas hourly
|
||||
const uptimeData = await Promise.all(
|
||||
dispositivos.map(async (d) => {
|
||||
const metricas = await ctx.prisma.dispositivoMetricaHourly.count({
|
||||
where: {
|
||||
dispositivoId: d.id,
|
||||
hora: {
|
||||
gte: input.desde,
|
||||
lte: input.hasta,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const horasTotales = Math.ceil(
|
||||
(input.hasta.getTime() - input.desde.getTime()) / (1000 * 60 * 60)
|
||||
)
|
||||
|
||||
const uptimePercent = horasTotales > 0 ? (metricas / horasTotales) * 100 : 0
|
||||
|
||||
return {
|
||||
dispositivo: d.nombre,
|
||||
tipo: d.tipo,
|
||||
cliente: d.cliente.nombre,
|
||||
horasOnline: metricas,
|
||||
horasTotales,
|
||||
uptimePercent: Math.min(100, Math.round(uptimePercent * 100) / 100),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
periodo: { desde: input.desde, hasta: input.hasta },
|
||||
dispositivos: uptimeData,
|
||||
promedioGeneral:
|
||||
uptimeData.length > 0
|
||||
? Math.round(
|
||||
(uptimeData.reduce((sum, d) => sum + d.uptimePercent, 0) /
|
||||
uptimeData.length) *
|
||||
100
|
||||
) / 100
|
||||
: 0,
|
||||
}
|
||||
}),
|
||||
|
||||
// Reporte de alertas
|
||||
alertas: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
desde: z.date(),
|
||||
hasta: z.date(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
const where = {
|
||||
createdAt: {
|
||||
gte: input.desde,
|
||||
lte: input.hasta,
|
||||
},
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
}
|
||||
|
||||
const [alertas, porSeveridad, porEstado, porDispositivo] = await Promise.all([
|
||||
ctx.prisma.alerta.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { nombre: true } },
|
||||
dispositivo: { select: { nombre: true, tipo: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
ctx.prisma.alerta.groupBy({
|
||||
by: ['severidad'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.alerta.groupBy({
|
||||
by: ['estado'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.alerta.groupBy({
|
||||
by: ['dispositivoId'],
|
||||
where: {
|
||||
...where,
|
||||
dispositivoId: { not: null },
|
||||
},
|
||||
_count: true,
|
||||
orderBy: { _count: { dispositivoId: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
])
|
||||
|
||||
// Obtener nombres de dispositivos top
|
||||
const topDispositivosIds = porDispositivo.map(p => p.dispositivoId).filter(Boolean) as string[]
|
||||
const topDispositivos = await ctx.prisma.dispositivo.findMany({
|
||||
where: { id: { in: topDispositivosIds } },
|
||||
select: { id: true, nombre: true },
|
||||
})
|
||||
const dispositivoMap = new Map(topDispositivos.map(d => [d.id, d.nombre]))
|
||||
|
||||
return {
|
||||
periodo: { desde: input.desde, hasta: input.hasta },
|
||||
total: alertas.length,
|
||||
alertas,
|
||||
resumen: {
|
||||
porSeveridad: porSeveridad.map(s => ({ severidad: s.severidad, count: s._count })),
|
||||
porEstado: porEstado.map(e => ({ estado: e.estado, count: e._count })),
|
||||
topDispositivos: porDispositivo.map(d => ({
|
||||
dispositivo: dispositivoMap.get(d.dispositivoId!) || 'Desconocido',
|
||||
count: d._count,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Reporte de actividad de usuarios
|
||||
actividad: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
desde: z.date(),
|
||||
hasta: z.date(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
const where = {
|
||||
createdAt: {
|
||||
gte: input.desde,
|
||||
lte: input.hasta,
|
||||
},
|
||||
...(clienteId ? {
|
||||
usuario: { clienteId },
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [logs, porAccion, porUsuario, sesiones] = await Promise.all([
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
usuario: { select: { nombre: true, email: true } },
|
||||
dispositivo: { select: { nombre: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['accion'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: { _count: { accion: 'desc' } },
|
||||
}),
|
||||
ctx.prisma.auditLog.groupBy({
|
||||
by: ['usuarioId'],
|
||||
where: {
|
||||
...where,
|
||||
usuarioId: { not: null },
|
||||
},
|
||||
_count: true,
|
||||
orderBy: { _count: { usuarioId: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
ctx.prisma.sesionRemota.findMany({
|
||||
where: {
|
||||
iniciadaEn: {
|
||||
gte: input.desde,
|
||||
lte: input.hasta,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
usuario: { select: { nombre: true } },
|
||||
dispositivo: { select: { nombre: true } },
|
||||
},
|
||||
orderBy: { iniciadaEn: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
// Obtener nombres de usuarios top
|
||||
const topUsuariosIds = porUsuario.map(p => p.usuarioId).filter(Boolean) as string[]
|
||||
const topUsuarios = await ctx.prisma.usuario.findMany({
|
||||
where: { id: { in: topUsuariosIds } },
|
||||
select: { id: true, nombre: true },
|
||||
})
|
||||
const usuarioMap = new Map(topUsuarios.map(u => [u.id, u.nombre]))
|
||||
|
||||
return {
|
||||
periodo: { desde: input.desde, hasta: input.hasta },
|
||||
logs,
|
||||
sesiones,
|
||||
resumen: {
|
||||
totalAcciones: logs.length,
|
||||
totalSesiones: sesiones.length,
|
||||
duracionTotalSesiones: sesiones.reduce((sum, s) => sum + (s.duracion || 0), 0),
|
||||
porAccion: porAccion.map(a => ({ accion: a.accion, count: a._count })),
|
||||
topUsuarios: porUsuario.map(u => ({
|
||||
usuario: usuarioMap.get(u.usuarioId!) || 'Desconocido',
|
||||
count: u._count,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Exportar reporte a CSV
|
||||
exportarCSV: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
tipo: z.enum(['inventario', 'alertas', 'actividad']),
|
||||
clienteId: z.string().optional(),
|
||||
desde: z.date().optional(),
|
||||
hasta: z.date().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input.clienteId
|
||||
|
||||
let data: string[][] = []
|
||||
let headers: string[] = []
|
||||
|
||||
switch (input.tipo) {
|
||||
case 'inventario': {
|
||||
headers = ['Cliente', 'Tipo', 'Nombre', 'IP', 'SO', 'Serial', 'Estado']
|
||||
const dispositivos = await ctx.prisma.dispositivo.findMany({
|
||||
where: clienteId ? { clienteId } : {},
|
||||
include: { cliente: { select: { nombre: true } } },
|
||||
})
|
||||
data = dispositivos.map(d => [
|
||||
d.cliente.nombre,
|
||||
d.tipo,
|
||||
d.nombre,
|
||||
d.ip || '',
|
||||
d.sistemaOperativo || '',
|
||||
d.serial || '',
|
||||
d.estado,
|
||||
])
|
||||
break
|
||||
}
|
||||
case 'alertas': {
|
||||
headers = ['Fecha', 'Cliente', 'Dispositivo', 'Severidad', 'Estado', 'Titulo', 'Mensaje']
|
||||
const alertas = await ctx.prisma.alerta.findMany({
|
||||
where: {
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(input.desde || input.hasta ? {
|
||||
createdAt: {
|
||||
...(input.desde ? { gte: input.desde } : {}),
|
||||
...(input.hasta ? { lte: input.hasta } : {}),
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
include: {
|
||||
cliente: { select: { nombre: true } },
|
||||
dispositivo: { select: { nombre: true } },
|
||||
},
|
||||
})
|
||||
data = alertas.map(a => [
|
||||
a.createdAt.toISOString(),
|
||||
a.cliente.nombre,
|
||||
a.dispositivo?.nombre || '',
|
||||
a.severidad,
|
||||
a.estado,
|
||||
a.titulo,
|
||||
a.mensaje,
|
||||
])
|
||||
break
|
||||
}
|
||||
case 'actividad': {
|
||||
headers = ['Fecha', 'Usuario', 'Accion', 'Recurso', 'Dispositivo', 'IP']
|
||||
const logs = await ctx.prisma.auditLog.findMany({
|
||||
where: {
|
||||
...(input.desde || input.hasta ? {
|
||||
createdAt: {
|
||||
...(input.desde ? { gte: input.desde } : {}),
|
||||
...(input.hasta ? { lte: input.hasta } : {}),
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
include: {
|
||||
usuario: { select: { nombre: true } },
|
||||
dispositivo: { select: { nombre: true } },
|
||||
},
|
||||
})
|
||||
data = logs.map(l => [
|
||||
l.createdAt.toISOString(),
|
||||
l.usuario?.nombre || '',
|
||||
l.accion,
|
||||
l.recurso,
|
||||
l.dispositivo?.nombre || '',
|
||||
l.ip || '',
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Generar CSV
|
||||
const csv = [
|
||||
headers.join(','),
|
||||
...data.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(',')),
|
||||
].join('\n')
|
||||
|
||||
return {
|
||||
filename: `reporte-${input.tipo}-${new Date().toISOString().split('T')[0]}.csv`,
|
||||
content: csv,
|
||||
contentType: 'text/csv',
|
||||
}
|
||||
}),
|
||||
})
|
||||
322
src/server/trpc/routers/usuarios.router.ts
Normal file
322
src/server/trpc/routers/usuarios.router.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure } from '../trpc'
|
||||
|
||||
export const usuariosRouter = router({
|
||||
// Listar usuarios
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
rol: z.enum(['SUPER_ADMIN', 'ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']).optional(),
|
||||
activo: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, rol, activo, search, page = 1, limit = 20 } = input || {}
|
||||
|
||||
// Si no es super admin, solo puede ver usuarios de su cliente
|
||||
const where = {
|
||||
...(ctx.user.rol !== 'SUPER_ADMIN' && ctx.user.clienteId
|
||||
? { clienteId: ctx.user.clienteId }
|
||||
: {}),
|
||||
...(clienteId ? { clienteId } : {}),
|
||||
...(rol ? { rol } : {}),
|
||||
...(activo !== undefined ? { activo } : {}),
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
||||
{ email: { contains: search, mode: 'insensitive' as const } },
|
||||
],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [usuarios, total] = await Promise.all([
|
||||
ctx.prisma.usuario.findMany({
|
||||
where,
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true } },
|
||||
},
|
||||
orderBy: { nombre: 'asc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.usuario.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
usuarios: usuarios.map(u => ({
|
||||
...u,
|
||||
passwordHash: undefined, // No exponer hash
|
||||
})),
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// Obtener usuario por ID
|
||||
byId: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const usuario = await ctx.prisma.usuario.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
cliente: true,
|
||||
permisos: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!usuario) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
|
||||
}
|
||||
|
||||
// Verificar acceso
|
||||
if (
|
||||
ctx.user.rol !== 'SUPER_ADMIN' &&
|
||||
ctx.user.clienteId &&
|
||||
usuario.clienteId !== ctx.user.clienteId
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
return {
|
||||
...usuario,
|
||||
passwordHash: undefined,
|
||||
}
|
||||
}),
|
||||
|
||||
// Crear usuario
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
nombre: z.string().min(1),
|
||||
password: z.string().min(8).optional(),
|
||||
clienteId: z.string().optional(),
|
||||
rol: z.enum(['ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']),
|
||||
telefono: z.string().optional(),
|
||||
notificarEmail: z.boolean().default(true),
|
||||
notificarSms: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Solo super admin puede crear sin clienteId
|
||||
if (!input.clienteId && ctx.user.rol !== 'SUPER_ADMIN') {
|
||||
input.clienteId = ctx.user.clienteId!
|
||||
}
|
||||
|
||||
// Verificar email unico
|
||||
const existe = await ctx.prisma.usuario.findUnique({
|
||||
where: { email: input.email },
|
||||
})
|
||||
if (existe) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Ya existe un usuario con ese email',
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = input.password
|
||||
? await bcrypt.hash(input.password, 12)
|
||||
: null
|
||||
|
||||
return ctx.prisma.usuario.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
nombre: input.nombre,
|
||||
passwordHash,
|
||||
clienteId: input.clienteId,
|
||||
rol: input.rol,
|
||||
telefono: input.telefono,
|
||||
notificarEmail: input.notificarEmail,
|
||||
notificarSms: input.notificarSms,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Actualizar usuario
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nombre: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
rol: z.enum(['ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']).optional(),
|
||||
activo: z.boolean().optional(),
|
||||
telefono: z.string().optional().nullable(),
|
||||
notificarEmail: z.boolean().optional(),
|
||||
notificarSms: z.boolean().optional(),
|
||||
clienteId: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
const usuario = await ctx.prisma.usuario.findUnique({ where: { id } })
|
||||
if (!usuario) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
|
||||
}
|
||||
|
||||
// Verificar acceso
|
||||
if (
|
||||
ctx.user.rol !== 'SUPER_ADMIN' &&
|
||||
ctx.user.clienteId &&
|
||||
usuario.clienteId !== ctx.user.clienteId
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
// Solo super admin puede cambiar clienteId
|
||||
if (data.clienteId !== undefined && ctx.user.rol !== 'SUPER_ADMIN') {
|
||||
delete data.clienteId
|
||||
}
|
||||
|
||||
return ctx.prisma.usuario.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}),
|
||||
|
||||
// Resetear password
|
||||
resetPassword: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
newPassword: z.string().min(8),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const usuario = await ctx.prisma.usuario.findUnique({ where: { id: input.id } })
|
||||
if (!usuario) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.user.rol !== 'SUPER_ADMIN' &&
|
||||
ctx.user.clienteId &&
|
||||
usuario.clienteId !== ctx.user.clienteId
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.newPassword, 12)
|
||||
|
||||
await ctx.prisma.usuario.update({
|
||||
where: { id: input.id },
|
||||
data: { passwordHash },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// Eliminar usuario
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const usuario = await ctx.prisma.usuario.findUnique({ where: { id: input.id } })
|
||||
if (!usuario) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.user.rol !== 'SUPER_ADMIN' &&
|
||||
ctx.user.clienteId &&
|
||||
usuario.clienteId !== ctx.user.clienteId
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
|
||||
// No permitir auto-eliminacion
|
||||
if (usuario.id === ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No puedes eliminar tu propio usuario',
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.prisma.usuario.delete({ where: { id: input.id } })
|
||||
}),
|
||||
|
||||
// Gestionar permisos
|
||||
permisos: router({
|
||||
list: adminProcedure
|
||||
.input(z.object({ usuarioId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.usuarioPermiso.findMany({
|
||||
where: { usuarioId: input.usuarioId },
|
||||
})
|
||||
}),
|
||||
|
||||
set: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
usuarioId: z.string(),
|
||||
permisos: z.array(
|
||||
z.object({
|
||||
recurso: z.string(),
|
||||
accion: z.string(),
|
||||
permitido: z.boolean(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Eliminar permisos existentes
|
||||
await ctx.prisma.usuarioPermiso.deleteMany({
|
||||
where: { usuarioId: input.usuarioId },
|
||||
})
|
||||
|
||||
// Crear nuevos permisos
|
||||
await ctx.prisma.usuarioPermiso.createMany({
|
||||
data: input.permisos.map(p => ({
|
||||
usuarioId: input.usuarioId,
|
||||
recurso: p.recurso,
|
||||
accion: p.accion,
|
||||
permitido: p.permitido,
|
||||
})),
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
}),
|
||||
|
||||
// Crear super admin (solo para setup inicial)
|
||||
createSuperAdmin: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
nombre: z.string().min(1),
|
||||
password: z.string().min(8),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existe = await ctx.prisma.usuario.findUnique({
|
||||
where: { email: input.email },
|
||||
})
|
||||
if (existe) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Ya existe un usuario con ese email',
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, 12)
|
||||
|
||||
return ctx.prisma.usuario.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
nombre: input.nombre,
|
||||
passwordHash,
|
||||
rol: 'SUPER_ADMIN',
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
87
src/server/trpc/trpc.ts
Normal file
87
src/server/trpc/trpc.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { initTRPC, TRPCError } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
import { ZodError } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { SessionUser } from '@/types'
|
||||
|
||||
export interface Context {
|
||||
user: SessionUser | null
|
||||
prisma: typeof prisma
|
||||
}
|
||||
|
||||
export async function createContext(): Promise<Context> {
|
||||
const user = await getSession()
|
||||
return {
|
||||
user,
|
||||
prisma,
|
||||
}
|
||||
}
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const router = t.router
|
||||
export const publicProcedure = t.procedure
|
||||
|
||||
// Middleware de autenticacion
|
||||
const isAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const protectedProcedure = t.procedure.use(isAuthenticated)
|
||||
|
||||
// Middleware para admin
|
||||
const isAdmin = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
|
||||
}
|
||||
if (!['SUPER_ADMIN', 'ADMIN'].includes(ctx.user.rol)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const adminProcedure = t.procedure.use(isAdmin)
|
||||
|
||||
// Middleware para super admin
|
||||
const isSuperAdmin = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
|
||||
}
|
||||
if (ctx.user.rol !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const superAdminProcedure = t.procedure.use(isSuperAdmin)
|
||||
100
src/types/index.ts
Normal file
100
src/types/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { TipoDispositivo, EstadoDispositivo, SeveridadAlerta, EstadoAlerta, RolUsuario } from '@prisma/client'
|
||||
|
||||
export type { TipoDispositivo, EstadoDispositivo, SeveridadAlerta, EstadoAlerta, RolUsuario }
|
||||
|
||||
export interface SessionUser {
|
||||
id: string
|
||||
email: string
|
||||
nombre: string
|
||||
rol: RolUsuario
|
||||
clienteId: string | null
|
||||
meshcentralUser: string | null
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalDispositivos: number
|
||||
dispositivosOnline: number
|
||||
dispositivosOffline: number
|
||||
dispositivosAlerta: number
|
||||
alertasActivas: number
|
||||
alertasCriticas: number
|
||||
sesionesActivas: number
|
||||
}
|
||||
|
||||
export interface DeviceMetrics {
|
||||
cpu: number
|
||||
ram: number
|
||||
disco: number
|
||||
temperatura?: number
|
||||
bateria?: number
|
||||
}
|
||||
|
||||
export interface ChartDataPoint {
|
||||
timestamp: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface AlertNotification {
|
||||
id: string
|
||||
severidad: SeveridadAlerta
|
||||
titulo: string
|
||||
mensaje: string
|
||||
dispositivo?: string
|
||||
cliente: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface MeshCentralDevice {
|
||||
_id: string
|
||||
name: string
|
||||
host: string
|
||||
agent?: {
|
||||
id: number
|
||||
caps: number
|
||||
}
|
||||
conn: number
|
||||
pwr: number
|
||||
ip?: string
|
||||
osdesc?: string
|
||||
hardware?: {
|
||||
identifiers?: {
|
||||
cpu_name?: string
|
||||
bios_serial?: string
|
||||
}
|
||||
windows?: {
|
||||
osinfo?: {
|
||||
Name?: string
|
||||
Version?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface LibreNMSDevice {
|
||||
device_id: number
|
||||
hostname: string
|
||||
sysName: string
|
||||
ip: string
|
||||
status: number
|
||||
os: string
|
||||
version: string
|
||||
hardware: string
|
||||
serial: string
|
||||
uptime: number
|
||||
location: string
|
||||
}
|
||||
|
||||
export interface HeadwindDevice {
|
||||
id: number
|
||||
number: string
|
||||
imei: string
|
||||
phone: string
|
||||
model: string
|
||||
manufacturer: string
|
||||
osVersion: string
|
||||
batteryLevel: number
|
||||
mdmMode: string
|
||||
lastUpdate: number
|
||||
lat?: number
|
||||
lon?: number
|
||||
}
|
||||
81
tailwind.config.ts
Normal file
81
tailwind.config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Tema cyan/navy oscuro
|
||||
primary: {
|
||||
50: '#ecfeff',
|
||||
100: '#cffafe',
|
||||
200: '#a5f3fc',
|
||||
300: '#67e8f9',
|
||||
400: '#22d3ee',
|
||||
500: '#06b6d4',
|
||||
600: '#0891b2',
|
||||
700: '#0e7490',
|
||||
800: '#155e75',
|
||||
900: '#164e63',
|
||||
950: '#083344',
|
||||
},
|
||||
navy: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
dark: {
|
||||
100: '#1e293b',
|
||||
200: '#1a2234',
|
||||
300: '#151c2c',
|
||||
400: '#111827',
|
||||
500: '#0d1321',
|
||||
600: '#0a0f1a',
|
||||
700: '#070b14',
|
||||
800: '#05080e',
|
||||
900: '#030509',
|
||||
},
|
||||
// Estados
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px rgba(6, 182, 212, 0.3)',
|
||||
'glow-lg': '0 0 40px rgba(6, 182, 212, 0.4)',
|
||||
'inner-glow': 'inset 0 0 20px rgba(6, 182, 212, 0.1)',
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||
},
|
||||
keyframes: {
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(6, 182, 212, 0.2)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(6, 182, 212, 0.6)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user