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