feat: Version 1.11.0 - Mejoras en auditoría, SLA, frontend y correcciones de sincronización
- Refactorización de endpoints de auditoría y helpers - Mejoras en esquemas de auditoría (audit.py) - Correcciones en endpoint SLA - Actualizaciones en múltiples rutas del frontend interno: layout, tickets, usuarios, tenants, categorías, sistemas, SLA (at-risk, violations), auditoría (main + security), login, perfil - Actualización de tailwind.config.js - Eliminación de docs de versiones anteriores (CAMBIOS_v1.10.0, v1.8.0, OPTIMIZACIONES) - Nuevos scripts de prueba: generate_security_test_data.py, generate_sla_test_data.py - Script de prueba de sincronización crítica (test_critical_sync.ps1) - README actualizado en scripts/
This commit is contained in:
@@ -1,726 +0,0 @@
|
||||
# ServiceManagerWeb — Versión 1.10.0
|
||||
## Reporte Técnico de Cambios y Mejoras
|
||||
|
||||
---
|
||||
|
||||
**Proyecto:** ServiceManagerWeb – Mesa de Ayuda B2B Multi-tenant
|
||||
**Versión:** 1.10.0
|
||||
**Versión base:** 1.8.0 (commit `e644039`) / 1.9.0 (commit `16d795e`)
|
||||
**Fecha:** 19 de Febrero de 2026
|
||||
**Estado:** Sistema Funcional — Producción MVP
|
||||
**Empresa:** Aduanasoft
|
||||
**Autor:** Equipo de Desarrollo
|
||||
|
||||
---
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
La versión 1.10.0 representa una fase de refactorización técnica profunda, optimización de rendimiento, y mejoras significativas en la interfaz de usuario. Los cambios abarcan el ciclo completo del sistema: backend (Python/FastAPI), frontend interno (SvelteKit), frontend cliente (SvelteKit) y la capa de infraestructura (Docker).
|
||||
|
||||
### Métricas Globales de esta Versión
|
||||
|
||||
| Indicador | Valor |
|
||||
|---|---|
|
||||
| Archivos modificados | 31 archivos |
|
||||
| Líneas añadidas (total) | ~3,250 líneas |
|
||||
| Líneas eliminadas (total) | ~3,440 líneas |
|
||||
| Reducción neta de código | ~190 líneas (refactorización limpia) |
|
||||
| Archivos nuevos creados | 14 archivos |
|
||||
| Archivos eliminados | 3 scripts de prueba temporales |
|
||||
| Scripts reorganizados | 3 (movidos a `backend/scripts/`) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend — Python / FastAPI
|
||||
|
||||
### 1.1 `backend/app/api/v1/endpoints/audit.py`
|
||||
**Cambios:** +166 líneas añadidas / −1,119 líneas eliminadas
|
||||
**Balance neto:** −953 líneas (reducción del 74% del archivo)
|
||||
|
||||
#### Problema detectado
|
||||
El archivo `audit.py` tenía 1,285 líneas en la versión 1.8.0. Toda la lógica de detección de amenazas, análisis de seguridad y transformación de datos estaba inline dentro de las funciones de cada endpoint, generando duplicación masiva y dificultando el mantenimiento.
|
||||
|
||||
#### Cambios realizados
|
||||
|
||||
**a) Extracción de lógica a módulo auxiliar**
|
||||
Se creó el archivo `backend/app/api/v1/audit_helpers.py` (nuevo, ver sección 1.8) con las siguientes funciones extraídas del archivo original:
|
||||
|
||||
```python
|
||||
# ANTES — en audit.py líneas 420-580 (inline)
|
||||
# Toda la lógica de detección de amenazas vivía dentro de la función
|
||||
# get_security_analysis() sin separación alguna
|
||||
|
||||
# DESPUÉS — importado desde audit_helpers.py
|
||||
from app.api.v1.audit_helpers import (
|
||||
audit_log_to_dict,
|
||||
apply_tenant_filter,
|
||||
get_count_stat,
|
||||
get_top_items,
|
||||
detect_mass_deletions,
|
||||
detect_brute_force,
|
||||
detect_privilege_escalation
|
||||
)
|
||||
```
|
||||
|
||||
**b) Docstrings compactados**
|
||||
Los docstrings multilínea extensos se compactaron a una sola línea donde el nombre era autoexplicativo:
|
||||
|
||||
```python
|
||||
# ANTES (líneas 1-7 del archivo original)
|
||||
"""
|
||||
Audit Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para consulta de logs de auditoría.
|
||||
Solo accesible por roles: ADMIN, SUPPORT_MANAGER, AUDITOR
|
||||
"""
|
||||
|
||||
# DESPUÉS (línea 1)
|
||||
"""Audit Endpoints - ServiceManagerWeb"""
|
||||
```
|
||||
|
||||
**c) Firma de función require_auditor_role refactorizada**
|
||||
```python
|
||||
# ANTES (líneas 32-47) — 16 líneas
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
Dependency que verifica que el usuario tenga rol de auditor.
|
||||
Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden ver logs de auditoría.
|
||||
"""
|
||||
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR..."
|
||||
)
|
||||
return current_user
|
||||
|
||||
# DESPUÉS (líneas 20-24) — 5 líneas
|
||||
def require_auditor_role(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Verifica que el usuario tenga rol de auditor"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder")
|
||||
return current_user
|
||||
```
|
||||
|
||||
**d) Firma del endpoint `get_audit_logs` compactada**
|
||||
```python
|
||||
# ANTES (líneas 49-72) — 24 líneas de parámetros separados
|
||||
|
||||
# DESPUÉS — parámetros agrupados en 6 líneas
|
||||
async def get_audit_logs(
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
user_id: Optional[uuid.UUID] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
...
|
||||
```
|
||||
|
||||
**e) Corrección del schema `SecurityAnalysisResponse`**
|
||||
Se añadieron todos los campos requeridos que causaban error 500 al serializar la respuesta. Los campos faltantes eran:
|
||||
- `analysis_period_hours`
|
||||
- `total_threats_detected`
|
||||
- `suspicious_ips_count`
|
||||
- `critical_actions_count`
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `backend/app/api/v1/endpoints/auth.py`
|
||||
**Cambios:** +369 líneas añadidas / −40 líneas eliminadas
|
||||
**Balance neto:** +329 líneas
|
||||
|
||||
#### Cambios realizados
|
||||
Se amplió la cobertura de autenticación con:
|
||||
- Manejo robusto de tokens de refresco
|
||||
- Validación mejorada de credenciales con mensajes de error específicos
|
||||
- Soporte para recuperación de contraseña por email
|
||||
- Integración con el servicio de email (`app/core/email.py`)
|
||||
- Logging estructurado con `structlog` en todos los endpoints críticos
|
||||
|
||||
---
|
||||
|
||||
### 1.3 `backend/app/api/v1/endpoints/tickets.py`
|
||||
**Cambios:** +217 líneas añadidas / −872 líneas eliminadas
|
||||
**Balance neto:** −655 líneas (reducción del 60%)
|
||||
|
||||
#### Problema detectado
|
||||
Igual que `audit.py`, el archivo de tickets tenía lógica de negocio repetida y funciones helper inline.
|
||||
|
||||
#### Cambios realizados
|
||||
|
||||
**a) Extracción a `backend/app/api/v1/helpers.py`**
|
||||
Se creó un módulo auxiliar general (nuevo, ver sección 1.9) con funciones reutilizables como:
|
||||
- `build_ticket_query()` — construye queries SQLAlchemy con filtros dinámicos
|
||||
- `paginate_query()` — paginación genérica reutilizable
|
||||
- `format_ticket_response()` — serialización consistente
|
||||
|
||||
**b) Corrección del error de `sla_breached`**
|
||||
```python
|
||||
# ANTES — causaba AttributeError / 500 en producción
|
||||
response.sla_breached = ticket.sla_breached # ← propiedad no existía
|
||||
|
||||
# DESPUÉS — removido, calculado dinámicamente
|
||||
# sla_breached se calcula desde sla_deadline vs datetime.utcnow()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 `backend/app/api/schemas/__init__.py`
|
||||
**Cambios:** +54 líneas añadidas / −4 líneas eliminadas
|
||||
|
||||
Se reorganizaron los schemas en módulos separados:
|
||||
|
||||
```
|
||||
# ANTES — un archivo monolítico schemas/__init__.py con todo
|
||||
|
||||
# DESPUÉS — módulos independientes por dominio:
|
||||
backend/app/api/schemas/
|
||||
├── __init__.py (re-exports, 58 líneas totales)
|
||||
├── auth.py (NUEVO — schemas de autenticación)
|
||||
├── category.py (NUEVO — schemas de categorías)
|
||||
├── system.py (NUEVO — schemas de sistemas)
|
||||
├── tenant.py (NUEVO — schemas de tenants)
|
||||
├── ticket.py (NUEVO — schemas de tickets)
|
||||
└── user.py (NUEVO — schemas de usuarios)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.5 `backend/app/middleware/tenant.py`
|
||||
**Cambios:** +103 líneas añadidas / −37 líneas eliminadas
|
||||
**Balance neto:** +66 líneas
|
||||
|
||||
#### Cambios realizados
|
||||
- Mejora del middleware de contexto multi-tenant con mejor manejo de headers `X-Tenant-ID`
|
||||
- Logging detallado de tenant context para debugging
|
||||
- Validación más robusta del tenant activo
|
||||
- Soporte para tenant bypass en endpoints de health/docs
|
||||
|
||||
---
|
||||
|
||||
### 1.6 `backend/app/main.py`
|
||||
**Cambios:** +7 líneas añadidas / −0 líneas eliminadas
|
||||
|
||||
```python
|
||||
# AÑADIDO — registro de nuevos routers
|
||||
from app.api.v1.endpoints import profile # router de perfil de usuario
|
||||
app.include_router(profile.router, prefix="/api/v1/profile", tags=["profile"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.7 `backend/app/core/database.py`
|
||||
**Cambios:** +3 líneas / −2 líneas
|
||||
|
||||
```python
|
||||
# ANTES
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
|
||||
# DESPUÉS — pool tuning para mayor concurrencia
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.8 `backend/app/api/v1/audit_helpers.py` (ARCHIVO NUEVO)
|
||||
**Líneas:** ~120 líneas
|
||||
|
||||
Módulo auxiliar extraído de `audit.py`. Contiene:
|
||||
|
||||
| Función | Descripción |
|
||||
|---|---|
|
||||
| `audit_log_to_dict(log)` | Serializa un AuditLog a dict |
|
||||
| `apply_tenant_filter(query, user, tenant, all_tenants)` | Aplica filtro multi-tenant |
|
||||
| `get_count_stat(db, model, filters)` | Cuenta registros con filtros |
|
||||
| `get_top_items(db, field, limit)` | Top N elementos de un campo |
|
||||
| `detect_mass_deletions(logs)` | Detecta patrones de eliminación masiva |
|
||||
| `detect_brute_force(logs)` | Detecta intentos de brute force |
|
||||
| `detect_privilege_escalation(logs)` | Detecta escalada de privilegios |
|
||||
|
||||
---
|
||||
|
||||
### 1.9 `backend/app/api/v1/helpers.py` (ARCHIVO NUEVO)
|
||||
**Líneas:** ~80 líneas
|
||||
|
||||
Helper general para tickets y recursos compartidos.
|
||||
|
||||
---
|
||||
|
||||
### 1.10 `backend/app/core/email.py` (ARCHIVO NUEVO)
|
||||
Módulo de envío de email para notificaciones y recuperación de contraseña, integrado con Celery workers.
|
||||
|
||||
---
|
||||
|
||||
### 1.11 `backend/app/core/cache.py` (ARCHIVO NUEVO)
|
||||
Módulo de caché con Redis:
|
||||
- `cache_get(key)` / `cache_set(key, value, ttl)`
|
||||
- Decorador `@cached(ttl=300)` para funciones async
|
||||
- Invalidación por patrón de claves
|
||||
|
||||
---
|
||||
|
||||
### 1.12 `backend/migrations/versions/a1b2c3d4e5f6_add_audit_logs_table.py`
|
||||
**Cambios:** +57 líneas / −1 línea
|
||||
|
||||
Migration completada: se añadió la tabla `security_incidents` con campos:
|
||||
- `id UUID PRIMARY KEY`
|
||||
- `title VARCHAR(255)`
|
||||
- `description TEXT`
|
||||
- `severity ENUM(low, medium, high, critical)`
|
||||
- `status ENUM(active, investigating, resolved)`
|
||||
- `tenant_id UUID FK`
|
||||
- `created_at TIMESTAMP`
|
||||
- `updated_at TIMESTAMP`
|
||||
|
||||
---
|
||||
|
||||
### 1.13 `backend/tests/conftest.py`
|
||||
**Cambios:** +151 líneas / −13 líneas
|
||||
|
||||
Se amplió el fixture base para pruebas de integración:
|
||||
- Fixtures para multi-tenant testing
|
||||
- Fixtures para usuario con rol AUDITOR
|
||||
- Data factories para tickets, incidentes y audit logs
|
||||
|
||||
---
|
||||
|
||||
### 1.14 Reorganización de Scripts
|
||||
```
|
||||
# ANTES — en raíz de backend/
|
||||
backend/check_tenants.py
|
||||
backend/create_test_user.py
|
||||
backend/set_test_password.py
|
||||
|
||||
# DESPUÉS — carpeta dedicada
|
||||
backend/scripts/check_tenants.py
|
||||
backend/scripts/create_test_user.py
|
||||
backend/scripts/set_test_password.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.15 Nuevos Tests Unitarios (archivos nuevos)
|
||||
```
|
||||
backend/tests/unit/
|
||||
├── test_audit_service.py (cobertura del AuditService)
|
||||
├── test_config.py (validación de settings)
|
||||
├── test_middleware.py (pruebas del middleware tenant)
|
||||
├── test_schemas.py (validación de schemas Pydantic)
|
||||
└── test_security.py (pruebas de JWT y hashing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Interno — SvelteKit / TypeScript
|
||||
|
||||
### 2.1 `frontend-internal/src/routes/audit/+page.svelte`
|
||||
**Cambios:** +1,253 líneas añadidas / −554 líneas eliminadas
|
||||
**Líneas totales finales:** 2,050 líneas
|
||||
|
||||
Este es el archivo con más cambios de toda la versión. Se realizó una refactorización completa de la página de auditoría.
|
||||
|
||||
#### 2.1.1 Tipado TypeScript — Corrección de Warnings
|
||||
|
||||
```typescript
|
||||
// ANTES (líneas 9-17) — tipos implícitos, generaba warnings
|
||||
let logs = [];
|
||||
let stats = null;
|
||||
let users = [];
|
||||
let incidents = [];
|
||||
let securityAnalysis = null;
|
||||
let selectedLog = null;
|
||||
let selectedIncident = null;
|
||||
|
||||
// DESPUÉS — tipos explícitos
|
||||
let logs: any[] = [];
|
||||
let stats: any = null;
|
||||
let users: any[] = [];
|
||||
let incidents: any[] = [];
|
||||
let securityAnalysis: any = null;
|
||||
let selectedLog: any = null;
|
||||
let selectedIncident: any = null;
|
||||
```
|
||||
|
||||
#### 2.1.2 Responses de API tipadas
|
||||
|
||||
```typescript
|
||||
// ANTES — response sin tipo, causaba errores
|
||||
const response = await api.get('/audit/security/incidents', params);
|
||||
incidents = response.incidents || []; // TS error: 'response' is of type 'unknown'
|
||||
|
||||
// DESPUÉS — response con tipo explícito
|
||||
const response: any = await api.get('/audit/security/incidents', params);
|
||||
incidents = response.incidents || [];
|
||||
```
|
||||
|
||||
#### 2.1.3 Catch blocks tipados (5 bloques corregidos)
|
||||
|
||||
```typescript
|
||||
// ANTES — 5 bloques con e sin tipo
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
|
||||
// DESPUÉS — todos tipados
|
||||
} catch (e: any) {
|
||||
console.error('Error:', e);
|
||||
```
|
||||
|
||||
#### 2.1.4 Botones de período refactorizados con array tipado
|
||||
|
||||
```typescript
|
||||
// ANTES — 5 bloques <button> repetidos, ~40 líneas
|
||||
<button on:click={() => changePeriod('today')} class="...">Hoy</button>
|
||||
<button on:click={() => changePeriod('yesterday')} class="...">Ayer</button>
|
||||
<button on:click={() => changePeriod('last7days')} class="...">Últimos 7 días</button>
|
||||
<button on:click={() => changePeriod('last30days')} class="...">Últimos 30 días</button>
|
||||
<button on:click={() => changePeriod('custom')} class="...">Personalizado</button>
|
||||
|
||||
// DESPUÉS — array con tipo estricto + loop, ~15 líneas
|
||||
const periodButtons: Array<{
|
||||
id: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom',
|
||||
label: string,
|
||||
icon?: boolean
|
||||
}> = [
|
||||
{ id: 'today', label: 'Hoy' },
|
||||
{ id: 'yesterday', label: 'Ayer' },
|
||||
{ id: 'last7days', label: 'Últimos 7 días' },
|
||||
{ id: 'last30days', label: 'Últimos 30 días' },
|
||||
{ id: 'custom', label: 'Personalizado', icon: true }
|
||||
];
|
||||
|
||||
{#each periodButtons as btn}
|
||||
<button on:click={() => changePeriod(btn.id)} class="...">
|
||||
{btn.label}
|
||||
</button>
|
||||
{/each}
|
||||
```
|
||||
|
||||
#### 2.1.5 Tarjetas estadísticas refactorizadas con array reactivo
|
||||
|
||||
```typescript
|
||||
// ANTES — 4 bloques <div> idénticos con ~25 líneas cada uno (~100 líneas totales)
|
||||
// Total del Acciones — bloque completo
|
||||
<div class="bg-white rounded-lg ...">
|
||||
<div class="..."><svg .../><span>Total de Registros</span></div>
|
||||
<div class="text-3xl ...">{stats.total_actions.toLocaleString()}</div>
|
||||
...
|
||||
</div>
|
||||
// Acciones Hoy — bloque completo (repetido)
|
||||
// Esta Semana — bloque completo (repetido)
|
||||
// Incidentes Críticos — bloque completo (repetido)
|
||||
|
||||
// DESPUÉS — array reactivo + loop, ~40 líneas totales
|
||||
$: statsCards = [
|
||||
{ label: 'Total de Registros', value: stats?.total_actions, icon: 'clipboard', color: 'gray', desc: '...' },
|
||||
{ label: 'Actividad Hoy', value: stats?.actions_today, icon: 'zap', color: 'blue', desc: '...' },
|
||||
{ label: 'Esta Semana', value: stats?.actions_this_week, icon: 'calendar', color: 'indigo', desc: '...' },
|
||||
{ label: 'Incidentes Críticos', value: stats?.critical_actions_today || 0, icon: 'alert', color: 'red', desc: '...', action: true }
|
||||
];
|
||||
|
||||
{#each statsCards as card}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-{card.color}-200 p-5 ...">
|
||||
...
|
||||
</div>
|
||||
{/each}
|
||||
```
|
||||
|
||||
#### 2.1.6 Sección de Análisis de Seguridad reemplazada por enlace
|
||||
|
||||
```svelte
|
||||
<!-- ANTES — sección extensa de ~150 líneas con amenazas, acciones recomendadas
|
||||
y métricas desplegadas inline en la página principal -->
|
||||
|
||||
<!-- DESPUÉS — tarjeta compacta (~50 líneas) con enlace a página dedicada -->
|
||||
<a href="/audit/security" class="block bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg ...">
|
||||
<!-- Resumen de 3 métricas clave -->
|
||||
<!-- Indicador visual del nivel de riesgo -->
|
||||
<!-- Enlace "Ir al análisis detallado" -->
|
||||
</a>
|
||||
```
|
||||
|
||||
Esta decisión separa la responsabilidad: la página `/audit` muestra el **resumen de actividad**, mientras que `/audit/security` muestra el **análisis detallado de amenazas**.
|
||||
|
||||
#### 2.1.7 Mejoras de espaciado y layout
|
||||
|
||||
- **Contenedor principal:** `px-4 sm:px-6 lg:px-8 py-8` — márgenes responsivos
|
||||
- **Encabezado de página:** añadido con `h1` + descripción
|
||||
- **Separación entre secciones:** `mb-8` uniforme (antes `mb-6` variable)
|
||||
- **Etiquetas de sección:** añadidos `<h2>` para "Resumen de Actividad", "Incidentes de Seguridad", "Registros de Auditoría"
|
||||
- **Tarjetas con headers descriptivos:** añadidos `<h3>` en toggles y controles
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
**Cambios:** +731 líneas añadidas / −338 líneas eliminadas
|
||||
|
||||
#### Cambios realizados
|
||||
- Corrección de ortografía en 11 etiquetas de texto (ej: "priorida" → "prioridad")
|
||||
- Mejora del filtro de estado y prioridad con selects correctamente bound a variables reactivas
|
||||
- Vista de tabla compacta con rows más ajustados (`py-2` en lugar de `py-4`)
|
||||
- Indicadores de color para prioridad (urgente=rojo, alto=naranja, medio=amarillo, bajo=azul)
|
||||
- Modal de detalle de ticket con información de SLA sin acceder a propiedades no existentes
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `frontend-internal/src/lib/components/Sidebar.svelte`
|
||||
**Cambios:** +17 líneas / −6 líneas
|
||||
|
||||
```svelte
|
||||
<!-- ANTES — enlace a Reportes ausente o comentado -->
|
||||
|
||||
<!-- DESPUÉS — enlace restaurado y activo -->
|
||||
<a href="/reports" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md ..."
|
||||
class:bg-indigo-700={$page.url.pathname.startsWith('/reports')}>
|
||||
Reportes
|
||||
</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `frontend-internal/vite.config.js`
|
||||
**Cambios:** +20 líneas / −16 líneas
|
||||
|
||||
```javascript
|
||||
// ANTES — proxy incorrecto durante desarrollo
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000' // ← fallaba dentro de Docker
|
||||
}
|
||||
|
||||
// DESPUÉS — proxy correcto para red Docker
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Nuevos Utilitarios Frontend (archivos nuevos)
|
||||
|
||||
#### `frontend-internal/src/lib/utils/colorUtils.ts` (NUEVO, 74 líneas)
|
||||
|
||||
```typescript
|
||||
// Centraliza todos los mapas de colores del sistema
|
||||
type ColorType = 'severity' | 'status' | 'action' | 'priority';
|
||||
|
||||
export function getColorClass(value: string, type: ColorType = 'status'): string
|
||||
export function getStatusIcon(status: string): string
|
||||
```
|
||||
|
||||
#### `frontend-internal/src/lib/utils/dateFormats.ts` (NUEVO, 78 líneas)
|
||||
|
||||
```typescript
|
||||
// Centraliza el formateo de fechas
|
||||
export function formatDate(dateString: string, format: DateFormat = 'full'): string
|
||||
export function getRelativeTime(dateString: string): string
|
||||
export function getDateRangeForPeriod(period: string, from?: string, to?: string): DateRange
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Cliente — SvelteKit / TypeScript
|
||||
|
||||
### 3.1 `frontend-client/src/routes/profile/+page.svelte`
|
||||
**Cambios:** +194 líneas / −56 líneas
|
||||
|
||||
Nueva funcionalidad de perfil de usuario con:
|
||||
- Visualización de datos personales del cliente
|
||||
- Formulario de edición de nombre y contacto
|
||||
- Cambio de contraseña con validación de fortaleza
|
||||
- Indicador visual del tipo de cuenta
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `frontend-client/vite.config.js`
|
||||
**Cambios:** +4 líneas / −0 líneas
|
||||
|
||||
```javascript
|
||||
// AÑADIDO — proxy para comunicación con backend
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://backend:8000', ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Nuevas Rutas Frontend Cliente (archivos nuevos)
|
||||
|
||||
```
|
||||
frontend-client/src/routes/
|
||||
├── forgot-password/ (NUEVO — flujo de recuperación de contraseña)
|
||||
├── reset-password/ (NUEVO — formulario de nueva contraseña con token)
|
||||
└── organization/ (NUEVO — vista de datos de la organización del cliente)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `frontend-client/src/lib/components/Header.svelte`
|
||||
**Cambios:** +8 líneas / −2 líneas
|
||||
|
||||
- Añadido enlace a perfil de usuario en el dropdown del header
|
||||
- Enlace a "Mi Organización" visible para `CLIENT_ADMIN`
|
||||
|
||||
---
|
||||
|
||||
## 4. Infraestructura y DevOps
|
||||
|
||||
### 4.1 `docker/Dockerfile.backend`
|
||||
**Cambios:** +3 líneas / −1 línea
|
||||
|
||||
```dockerfile
|
||||
# AÑADIDO — dependencias del sistema para compilar bcrypt
|
||||
RUN apt-get install -y build-essential libffi-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `frontend-internal/package.json`
|
||||
**Cambios:** +1 línea / −1 línea
|
||||
|
||||
```json
|
||||
// ACTUALIZADO — versión de @sveltejs/kit para fix de routing
|
||||
"@sveltejs/kit": "^1.27.0" // antes ^1.6.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Archivos Eliminados
|
||||
|
||||
| Archivo | Razón |
|
||||
|---|---|
|
||||
| `test_frontend_integration.ps1` (174 líneas) | Script de prueba temporal — funcionalidad absorbida por suite de tests |
|
||||
| `test_manual.ps1` (142 líneas) | Script de prueba manual obsoleto |
|
||||
| `test_tenant_update.ps1` (101 líneas) | Script específico para prueba puntual, ya no necesario |
|
||||
|
||||
**Total eliminado:** 417 líneas de código temporal/obsoleto
|
||||
|
||||
---
|
||||
|
||||
## 6. Nuevos Archivos Creados
|
||||
|
||||
| Archivo | Líneas | Propósito |
|
||||
|---|---|---|
|
||||
| `backend/app/api/v1/audit_helpers.py` | ~120 | Helpers de auditoría extraídos de audit.py |
|
||||
| `backend/app/api/v1/helpers.py` | ~80 | Helpers generales de tickets y queries |
|
||||
| `backend/app/api/schemas/auth.py` | ~60 | Schemas Pydantic para autenticación |
|
||||
| `backend/app/api/schemas/category.py` | ~30 | Schemas de categorías |
|
||||
| `backend/app/api/schemas/system.py` | ~30 | Schemas de sistemas |
|
||||
| `backend/app/api/schemas/tenant.py` | ~40 | Schemas de tenants |
|
||||
| `backend/app/api/schemas/ticket.py` | ~80 | Schemas de tickets |
|
||||
| `backend/app/api/schemas/user.py` | ~50 | Schemas de usuarios |
|
||||
| `backend/app/core/email.py` | ~90 | Servicio de envío de email |
|
||||
| `backend/app/core/cache.py` | ~70 | Módulo de caché Redis |
|
||||
| `backend/tests/unit/test_audit_service.py` | ~100 | Tests del servicio de auditoría |
|
||||
| `backend/tests/unit/test_config.py` | ~50 | Tests de configuración |
|
||||
| `backend/tests/unit/test_middleware.py` | ~80 | Tests del middleware tenant |
|
||||
| `backend/tests/unit/test_schemas.py` | ~70 | Tests de validación de schemas |
|
||||
| `backend/tests/unit/test_security.py` | ~60 | Tests de seguridad JWT |
|
||||
| `frontend-internal/src/lib/utils/colorUtils.ts` | 74 | Centralización de colores |
|
||||
| `frontend-internal/src/lib/utils/dateFormats.ts` | 78 | Centralización de formatos de fecha |
|
||||
| `frontend-client/src/routes/forgot-password/` | ~80 | Flujo de recuperación de contraseña |
|
||||
| `frontend-client/src/routes/reset-password/` | ~90 | Formulario reset con token |
|
||||
| `frontend-client/src/routes/organization/` | ~120 | Vista de organización del cliente |
|
||||
| `frontend-internal/src/routes/profile/` | ~150 | Perfil del usuario interno |
|
||||
| `OPTIMIZACIONES_RENDIMIENTO.md` | 344 | Guía técnica de optimizaciones futuras |
|
||||
|
||||
---
|
||||
|
||||
## 7. Correcciones de Bugs
|
||||
|
||||
### Bug #1 — Error 500 en `/audit/security/analysis`
|
||||
**Causa:** El schema `SecurityAnalysisResponse` de Pydantic no incluía los campos `analysis_period_hours`, `total_threats_detected`, `suspicious_ips_count`, `critical_actions_count`. Al intentar serializar la respuesta, Pydantic lanzaba `ValidationError`.
|
||||
**Archivo:** `backend/app/api/v1/endpoints/audit.py`
|
||||
**Fix:** Se añadieron los campos faltantes al schema de respuesta en `backend/app/api/schemas/__init__.py`.
|
||||
|
||||
### Bug #2 — Error 500 en detalle de ticket (`/tickets/{id}`)
|
||||
**Causa:** El endpoint accedía a `ticket.sla_breached` que no es una columna de la tabla, sino un cálculo derivado.
|
||||
**Archivo:** `backend/app/api/v1/endpoints/tickets.py`
|
||||
**Fix:** Se eliminó la referencia a `ticket.sla_breached` y se calcula dinámicamente: `sla_breached = ticket.sla_deadline < datetime.utcnow() if ticket.sla_deadline else False`
|
||||
|
||||
### Bug #3 — Proxy 404 en desarrollo con Docker
|
||||
**Causa:** `vite.config.js` apuntaba a `localhost:8000` en lugar del hostname Docker `backend:8000`.
|
||||
**Archivos:** `frontend-internal/vite.config.js`, `frontend-client/vite.config.js`
|
||||
**Fix:** Se actualizó el target del proxy a `http://backend:8000` con `changeOrigin: true`.
|
||||
|
||||
### Bug #4 — Filtros de tickets no aplicaban
|
||||
**Causa:** Los parámetros `status` y `priority` del frontend construían query strings con nombres incorrectos (`status_filter` en vez de `status`).
|
||||
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
**Fix:** Corregidos los nombres de parámetros para coincidir con los Query params del backend.
|
||||
|
||||
### Bug #5 — Archivos con prefijo `+` causaban error de SvelteKit
|
||||
**Causa:** Durante el desarrollo se crearon archivos de respaldo con nombres `+page.svelte.backup` y `+page.svelte.tmp`. SvelteKit interpreta cualquier archivo con `+` como una ruta especial.
|
||||
**Fix:** Se eliminaron todos los archivos de respaldo con formato `+*.tmp`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Correcciones Ortográficas (frontend-internal)
|
||||
|
||||
En `frontend-internal/src/routes/tickets/+page.svelte` se corrigieron 11 errores ortográficos:
|
||||
|
||||
| Línea aprox. | Antes | Después |
|
||||
|---|---|---|
|
||||
| ~145 | `priorida` | `prioridad` |
|
||||
| ~189 | `Estad` | `Estado` |
|
||||
| ~234 | `Accionnes` | `Acciones` |
|
||||
| ~267 | `Assigado` | `Asignado` |
|
||||
| ~310 | `Fecah` | `Fecha` |
|
||||
| ~345 | `Prioiridad` | `Prioridad` |
|
||||
| ~389 | `Ticktes` | `Tickets` |
|
||||
| ~412 | `Resolucion` | `Resolución` |
|
||||
| ~456 | `Sataus` | `Status` |
|
||||
| ~478 | `Critcio` | `Crítico` |
|
||||
| ~501 | `Asignar` → etiqueta incorrecta | Texto corregido contextualmente |
|
||||
|
||||
---
|
||||
|
||||
## 9. Notas de Migración
|
||||
|
||||
Para actualizar de v1.8.0 / v1.9.0 a v1.10.0:
|
||||
|
||||
```bash
|
||||
# 1. Actualizar código
|
||||
git pull origin main
|
||||
git checkout version-1.10.0
|
||||
|
||||
# 2. Aplicar migraciones de base de datos
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# 3. Reconstruir imágenes (cambios en Dockerfile)
|
||||
docker-compose build --no-cache backend
|
||||
|
||||
# 4. Reiniciar todos los servicios
|
||||
docker-compose up -d
|
||||
|
||||
# 5. Verificar salud
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Estado del Sistema tras v1.10.0
|
||||
|
||||
| Componente | Estado | Notas |
|
||||
|---|---|---|
|
||||
| Backend FastAPI | ✅ Funcional | 0 errores 500 en endpoints principales |
|
||||
| Frontend Interno | ✅ Funcional | Proxy Docker correcto |
|
||||
| Frontend Cliente | ✅ Funcional | Nuevas rutas de perfil y organización |
|
||||
| Base de Datos | ✅ Migrada | Tabla security_incidents disponible |
|
||||
| Celery Workers | ✅ Funcional | Integrado con email service |
|
||||
| Redis Cache | ✅ Funcional | Módulo cache.py implementado |
|
||||
| Tests Unitarios | ✅ Nuevos | 5 nuevos archivos de tests |
|
||||
| Docker Compose | ✅ Funcional | Todos los servicios healthy |
|
||||
|
||||
---
|
||||
|
||||
*Documento generado: 19 de Febrero de 2026*
|
||||
*Versión del documento: 1.0*
|
||||
*ServiceManagerWeb — Aduanasoft*
|
||||
@@ -1,847 +0,0 @@
|
||||
# ServiceManagerWeb - Versión 1.8.0
|
||||
## Reporte Técnico de Cambios y Mejoras
|
||||
|
||||
---
|
||||
|
||||
**Proyecto:** ServiceManagerWeb - Mesa de Ayuda B2B Multi-tenant
|
||||
**Versión:** 1.8.0
|
||||
**Fecha:** 17 de Febrero de 2026
|
||||
**Estado:** Sistema Funcional para Producción MVP
|
||||
**Empresa:** Aduanasoft
|
||||
|
||||
---
|
||||
|
||||
## 📋 Resumen Ejecutivo
|
||||
|
||||
La versión 1.8.0 representa un hito importante en el desarrollo del sistema, consolidando la funcionalidad completa del módulo de tickets con un sistema de filtros operativo, optimizaciones significativas en la interfaz de usuario, y correcciones críticas en el backend. Esta versión está lista para despliegue en ambiente de producción MVP.
|
||||
|
||||
### Indicadores de Mejora
|
||||
- **Densidad de información:** +50% más registros visibles por pantalla
|
||||
- **Tiempo de respuesta UI:** Reducción de ~200ms en renderizado de tablas
|
||||
- **Cobertura de filtros:** 100% funcional (estado y prioridad)
|
||||
- **Correcciones backend:** 3 endpoints críticos corregidos
|
||||
- **Archivos modificados:** 8 archivos (245 inserciones, 1633 eliminaciones)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Objetivos Alcanzados
|
||||
|
||||
### 1. Sistema de Filtros Funcional
|
||||
**Problema:** Los filtros en el módulo de tickets no funcionaban correctamente, mostrando todos los registros sin importar los criterios seleccionados.
|
||||
|
||||
**Solución Implementada:**
|
||||
- Rediseño completo del sistema de filtros frontend/backend
|
||||
- Implementación correcta de construcción de query strings
|
||||
- Validación de parámetros en backend con mensajes de error descriptivos
|
||||
|
||||
**Resultado:** Filtrado 100% funcional por estado y prioridad con actualización automática.
|
||||
|
||||
### 2. Optimización de Interfaz de Usuario
|
||||
**Problema:** Las tablas ocupaban demasiado espacio vertical, reduciendo la cantidad de información visible.
|
||||
|
||||
**Solución Implementada:**
|
||||
- Adopción del estilo compacto del módulo de auditoría
|
||||
- Reducción de padding y tamaños de fuente
|
||||
- Eliminación de columnas redundantes
|
||||
|
||||
**Resultado:** 50% más contenido visible sin sacrificar legibilidad.
|
||||
|
||||
### 3. Correcciones Backend Críticas
|
||||
**Problema:** Múltiples endpoints presentaban errores 500 en producción.
|
||||
|
||||
**Solución Implementada:**
|
||||
- Corrección de manejo de timezone en comparaciones
|
||||
- Implementación de eager loading para relaciones
|
||||
- Generación explícita de UUIDs en creación de perfiles
|
||||
|
||||
**Resultado:** 0 errores 500 en endpoints principales.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Cambios Técnicos Detallados
|
||||
|
||||
### Backend (Python/FastAPI)
|
||||
|
||||
#### 1. Endpoint `/v1/tickets/` - Sistema de Filtros
|
||||
**Archivo:** `backend/app/api/v1/endpoints/tickets.py`
|
||||
|
||||
**Cambios realizados:**
|
||||
```python
|
||||
# ANTES (no funcional)
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status_filter: Optional[str] = None, # ❌ Nombre inconsistente
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Solo filtro por status, sin prioridad
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
|
||||
# DESPUÉS (funcional)
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None, # ✅ Nombre correcto
|
||||
priority: Optional[str] = None, # ✅ Filtro agregado
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Filtro por estado con validación
|
||||
if status:
|
||||
try:
|
||||
status_enum = TicketStatus[status.upper()]
|
||||
query = query.where(Ticket.status == status_enum)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status: {status}. Valid values: NEW, IN_PROGRESS, ..."
|
||||
)
|
||||
|
||||
# Filtro por prioridad con validación
|
||||
if priority:
|
||||
try:
|
||||
priority_enum = TicketPriority[priority.upper()]
|
||||
query = query.where(Ticket.priority == priority_enum)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid priority: {priority}. Valid values: LOW, MEDIUM, HIGH, URGENT"
|
||||
)
|
||||
```
|
||||
|
||||
**Impacto:**
|
||||
- Frontend y backend ahora usan los mismos nombres de parámetros
|
||||
- Validación explícita previene errores de datos inválidos
|
||||
- Soporte completo para filtrado combinado (estado + prioridad)
|
||||
- Mensajes de error descriptivos facilitan debugging
|
||||
|
||||
---
|
||||
|
||||
#### 2. Endpoint `/v1/sla/violations` - Corrección de Timezone
|
||||
**Archivo:** `backend/app/api/v1/endpoints/sla.py`
|
||||
|
||||
**Problema identificado:**
|
||||
```
|
||||
TypeError: can't compare offset-naive and offset-aware datetimes
|
||||
```
|
||||
|
||||
**Causa raíz:**
|
||||
El campo `ticket.sla_response_due` viene de la base de datos como timestamp **naive** (sin zona horaria), pero `datetime.now(timezone.utc)` genera un timestamp **aware** (con UTC), causando incompatibilidad en comparaciones.
|
||||
|
||||
**Solución implementada:**
|
||||
```python
|
||||
# ANTES
|
||||
if ticket.sla_response_due:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now > ticket.sla_response_due: # ❌ Error: comparación incompatible
|
||||
violated_tickets.append(...)
|
||||
|
||||
# DESPUÉS
|
||||
if ticket.sla_response_due:
|
||||
now = datetime.now(timezone.utc)
|
||||
# Convertir timestamp de BD a UTC-aware
|
||||
sla_due_aware = ticket.sla_response_due.replace(tzinfo=timezone.utc)
|
||||
if now > sla_due_aware: # ✅ Ambos son UTC-aware
|
||||
violated_tickets.append(...)
|
||||
```
|
||||
|
||||
**Mejora adicional:** Eager Loading
|
||||
```python
|
||||
# ANTES: N+1 queries problem
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
for ticket in tickets:
|
||||
user_email = ticket.created_by_user.email # ❌ Query adicional por cada ticket
|
||||
|
||||
# DESPUÉS: Single query con JOIN
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
query = query.options(
|
||||
selectinload(Ticket.created_by_user),
|
||||
selectinload(Ticket.assigned_to_user),
|
||||
selectinload(Ticket.category)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
# ✅ Todas las relaciones cargadas en una sola consulta
|
||||
```
|
||||
|
||||
**Impacto:**
|
||||
- Eliminación de errores de comparación de timezone
|
||||
- Reducción de queries a BD de O(n) a O(1)
|
||||
- Mejora de rendimiento en listados grandes
|
||||
|
||||
---
|
||||
|
||||
#### 3. Endpoint `/v1/client-profile/` - Generación de UUID
|
||||
**Archivo:** `backend/app/api/v1/endpoints/client_profile.py`
|
||||
|
||||
**Problema:**
|
||||
```
|
||||
IntegrityError: null value in column "id" violates not-null constraint
|
||||
IntegrityError: null value in column "created_at" violates not-null constraint
|
||||
```
|
||||
|
||||
**Causa raíz:**
|
||||
SQLAlchemy esperaba que la base de datos generara el UUID automáticamente, pero la columna no tenía `DEFAULT` en PostgreSQL.
|
||||
|
||||
**Solución implementada:**
|
||||
|
||||
1. **Código de aplicación:**
|
||||
```python
|
||||
# ANTES
|
||||
db_profile = ClientProfile(
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id
|
||||
# ❌ Falta id y created_at
|
||||
)
|
||||
|
||||
# DESPUÉS
|
||||
import uuid
|
||||
db_profile = ClientProfile(
|
||||
id=uuid.uuid4(), # ✅ Generación explícita
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
```
|
||||
|
||||
2. **Migración de base de datos:**
|
||||
```python
|
||||
# Archivo: backend/migrations/versions/fix_client_profiles_timestamps.py
|
||||
def upgrade():
|
||||
op.alter_column('client_profiles', 'created_at',
|
||||
server_default=sa.text('now()'))
|
||||
op.alter_column('client_profiles', 'updated_at',
|
||||
server_default=sa.text('now()'))
|
||||
|
||||
def downgrade():
|
||||
op.alter_column('client_profiles', 'created_at',
|
||||
server_default=None)
|
||||
op.alter_column('client_profiles', 'updated_at',
|
||||
server_default=None)
|
||||
```
|
||||
|
||||
**Impacto:**
|
||||
- Eliminación de errores 500 al crear perfiles vacíos
|
||||
- Base de datos con defaults consistentes
|
||||
- Código más robusto y predecible
|
||||
|
||||
---
|
||||
|
||||
### Frontend (SvelteKit/TypeScript)
|
||||
|
||||
#### 1. Módulo de Tickets - Sistema de Filtros
|
||||
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
|
||||
**Arquitectura del cambio:**
|
||||
|
||||
```typescript
|
||||
// ANTES: Parámetros incorrectamente estructurados
|
||||
async function loadData() {
|
||||
const params: Record<string, string> = {};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterPriority) params.priority = filterPriority;
|
||||
|
||||
// ❌ El helper api.get() no construía correctamente la URL con params objeto
|
||||
const data = await api.get('/tickets/', params);
|
||||
}
|
||||
|
||||
// DESPUÉS: Query string explícito
|
||||
async function loadData() {
|
||||
// Usar URLSearchParams para construcción correcta
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('skip', '0');
|
||||
queryParams.append('limit', '100');
|
||||
|
||||
if (filterStatus) {
|
||||
queryParams.append('status', filterStatus);
|
||||
}
|
||||
if (filterPriority) {
|
||||
queryParams.append('priority', filterPriority);
|
||||
}
|
||||
|
||||
// ✅ URL completa con query string bien formado
|
||||
const endpoint = `/tickets/?${queryParams.toString()}`;
|
||||
const data = await api.get(endpoint);
|
||||
}
|
||||
```
|
||||
|
||||
**Layout de filtros optimizado:**
|
||||
```svelte
|
||||
<!-- ANTES: 3 columnas con botón actualizar manual -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Estado</label>
|
||||
<select bind:value={filterStatus} on:change={applyFilters}
|
||||
class="mt-1 block w-full border p-2">
|
||||
<option value="">Todos</option>
|
||||
<!-- ... -->
|
||||
</select>
|
||||
</div>
|
||||
<div><!-- Prioridad --></div>
|
||||
<div class="flex items-end">
|
||||
<button on:click={loadData}>Actualizar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESPUÉS: 2 columnas con auto-actualización -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Estado</label>
|
||||
<select bind:value={filterStatus} on:change={loadData}
|
||||
class="block w-full border p-1.5 text-sm">
|
||||
<option value="">Todos los estados</option>
|
||||
<!-- ... -->
|
||||
</select>
|
||||
</div>
|
||||
<div><!-- Prioridad con mismo patrón --></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Menor espacio vertical ocupado por filtros
|
||||
- Actualización inmediata al cambiar criterios
|
||||
- Interfaz más limpia sin botones innecesarios
|
||||
- Labels más pequeños pero legibles
|
||||
|
||||
---
|
||||
|
||||
#### 2. Tabla de Tickets - Diseño Compacto
|
||||
**Archivo:** `frontend-internal/src/routes/tickets/+page.svelte`
|
||||
|
||||
**Comparación de estilos:**
|
||||
|
||||
| Elemento | Antes (v1.7.1) | Después (v1.8.0) | Reducción |
|
||||
|----------|----------------|------------------|-----------|
|
||||
| **Header padding** | `py-2` (8px) | `py-1.5` (6px) | -25% |
|
||||
| **Cell padding** | `px-2 py-2` | `px-3 py-2` | 0% (optimizado) |
|
||||
| **Font size header** | `text-xs font-semibold` | `text-xs font-medium uppercase` | Mejor jerarquía |
|
||||
| **Font size body** | `text-xs` | `text-xs` | Mantenido |
|
||||
| **Badge padding** | `px-2 py-0.5` | `px-2 py-1` | Mejor legibilidad |
|
||||
| **Columnas totales** | 9 (inc. SLA) | 8 (sin SLA) | -11% ancho |
|
||||
|
||||
**Estructura HTML mejorada:**
|
||||
```html
|
||||
<!-- ANTES -->
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="py-2 pl-4 pr-2 text-xs font-semibold text-gray-900">Ticket</th>
|
||||
<th class="px-2 py-2 text-xs font-semibold">Asunto</th>
|
||||
<!-- ... 7 columnas más incluyendo SLA -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr class="hover:bg-gray-50 cursor-pointer">
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-2">...</td>
|
||||
<!-- ... -->
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- DESPUÉS -->
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-3 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ticket
|
||||
</th>
|
||||
<th class="px-3 py-1.5 text-xs font-medium uppercase">Asunto</th>
|
||||
<!-- ... 6 columnas más, SLA eliminado -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr class="hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-xs font-medium">...</td>
|
||||
<!-- ... -->
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
**Mejoras visuales:**
|
||||
- **Sticky header:** `sticky top-0 z-10` - encabezados fijos al hacer scroll
|
||||
- **Transitions:** `transition-colors` en hover para mejor UX
|
||||
- **Consistency:** Mismo padding `px-3` en todo el ancho
|
||||
- **Typography:** `uppercase tracking-wider` en headers para mejor escaneado
|
||||
- **Dividers:** Cambio de `divide-gray-300` a `divide-gray-200` (más sutil)
|
||||
|
||||
**Badges optimizados:**
|
||||
```svelte
|
||||
<!-- ANTES: Inline badges con tamaños variables -->
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold leading-4
|
||||
bg-{getStatusBadge(ticket.status).color}-100">
|
||||
{getStatusBadge(ticket.status).label}
|
||||
</span>
|
||||
|
||||
<!-- DESPUÉS: Badges uniformes con mejor padding -->
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full
|
||||
bg-{getStatusBadge(ticket.status).color}-100
|
||||
text-{getStatusBadge(ticket.status).color}-800">
|
||||
{getStatusBadge(ticket.status).label}
|
||||
</span>
|
||||
```
|
||||
|
||||
**Acciones con separador visual:**
|
||||
```svelte
|
||||
<!-- ANTES: Botones sin separación clara -->
|
||||
<td class="space-x-1">
|
||||
<button class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button class="text-red-600 hover:text-red-900">Eliminar</button>
|
||||
</td>
|
||||
|
||||
<!-- DESPUÉS: Separador visual con transiciones -->
|
||||
<td class="px-3 py-2 whitespace-nowrap text-right text-xs">
|
||||
<button class="text-indigo-600 hover:text-indigo-900 font-medium transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
<span class="text-gray-300 mx-1">|</span>
|
||||
<button class="text-red-600 hover:text-red-900 font-medium transition-colors">
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. Gestión de Tenants - Toggle de Estado
|
||||
**Archivo:** `frontend-internal/src/routes/tenants/+page.svelte`
|
||||
|
||||
**Funcionalidad agregada:** Toggle switch para activar/desactivar tenants
|
||||
|
||||
**Implementación:**
|
||||
```svelte
|
||||
<script>
|
||||
async function toggleTenantStatus(tenant: any) {
|
||||
try {
|
||||
const newStatus = tenant.status === 'active' ? 'inactive' : 'active';
|
||||
await api.patch(`/tenants/${tenant.id}`, { status: newStatus });
|
||||
|
||||
// Actualizar estado local con reactividad forzada
|
||||
tenant.status = newStatus;
|
||||
tenants = [...tenants]; // ✅ Spread operator fuerza re-render
|
||||
|
||||
toast.success(`Tenant ${newStatus === 'active' ? 'activado' : 'desactivado'}`);
|
||||
} catch (e) {
|
||||
toast.error('Error al cambiar estado: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Toggle switch estilizado -->
|
||||
<button
|
||||
on:click|stopPropagation={() => toggleTenantStatus(tenant)}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
||||
{tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-200'}"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
{tenant.status === 'active' ? 'translate-x-6' : 'translate-x-1'}">
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Reactividad con keyed loop -->
|
||||
{#each tenants as tenant (tenant.id)}
|
||||
<!-- ✅ Key binding asegura updates correctos -->
|
||||
{/each}
|
||||
```
|
||||
|
||||
**Conceptos aplicados:**
|
||||
- **Svelte Reactivity:** Uso de spread operator `[...tenants]` para forzar re-render
|
||||
- **Keyed loops:** `{#each tenants as tenant (tenant.id)}` previene bugs de reordenamiento
|
||||
- **Event modifiers:** `on:click|stopPropagation` previene navegación accidental
|
||||
- **CSS Transitions:** Animación suave en cambio de estado
|
||||
|
||||
---
|
||||
|
||||
## 📊 Análisis de Impacto
|
||||
|
||||
### Rendimiento
|
||||
|
||||
| Métrica | v1.7.1 | v1.8.0 | Mejora |
|
||||
|---------|--------|--------|--------|
|
||||
| **Queries por listado de tickets** | 21 (1 + 20*1 N+1) | 1 (eager loading) | 95% ↓ |
|
||||
| **Tiempo de render tabla** | ~350ms | ~150ms | 57% ↓ |
|
||||
| **Registros visibles** | 6-7 tickets | 12-14 tickets | 100% ↑ |
|
||||
| **Filtros funcionales** | 0% | 100% | ∞ ↑ |
|
||||
| **Errores 500 endpoints** | 3 endpoints | 0 endpoints | 100% ↓ |
|
||||
|
||||
### Calidad de Código
|
||||
|
||||
```
|
||||
Archivos modificados: 8
|
||||
Líneas agregadas: +245
|
||||
Líneas eliminadas: -1,633
|
||||
Ratio de limpieza: 6.7:1 (eliminamos más código del que agregamos)
|
||||
```
|
||||
|
||||
**Archivos principales:**
|
||||
1. `backend/app/api/v1/endpoints/tickets.py` - Sistema de filtros
|
||||
2. `backend/app/api/v1/endpoints/sla.py` - Corrección timezone
|
||||
3. `backend/app/api/v1/endpoints/client_profile.py` - UUID explicit
|
||||
4. `frontend-internal/src/routes/tickets/+page.svelte` - UI optimizada
|
||||
5. `frontend-internal/src/routes/tenants/+page.svelte` - Toggle status
|
||||
6. `backend/migrations/versions/fix_client_profiles_timestamps.py` - Nueva migración
|
||||
|
||||
### Deuda Técnica
|
||||
|
||||
**Eliminada:**
|
||||
- ✅ N+1 queries en endpoint de SLA violations
|
||||
- ✅ Comparaciones timezone incompatibles
|
||||
- ✅ Filtros no funcionales en tickets
|
||||
- ✅ Código duplicado en tablas (archivos .backup eliminados)
|
||||
|
||||
**Pendiente (no crítica):**
|
||||
- ⚠️ Paginación en frontend (actualmente limit 100)
|
||||
- ⚠️ Tests automatizados para nuevos endpoints
|
||||
- ⚠️ Caché de categorías/sistemas/usuarios (cargados en cada request)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing y Validación
|
||||
|
||||
### Tests Realizados
|
||||
|
||||
#### 1. Sistema de Filtros
|
||||
```
|
||||
✅ Filtro por estado "NEW" → Solo tickets nuevos
|
||||
✅ Filtro por prioridad "HIGH" → Solo tickets alta prioridad
|
||||
✅ Filtro combinado (NEW + HIGH) → Intersección correcta
|
||||
✅ Limpieza de filtros → Todos los tickets visibles
|
||||
✅ Estados inválidos → Error 400 con mensaje descriptivo
|
||||
```
|
||||
|
||||
#### 2. Endpoints Backend
|
||||
```
|
||||
✅ GET /v1/tickets/?status=NEW → 200 OK
|
||||
✅ GET /v1/tickets/?priority=URGENT → 200 OK
|
||||
✅ GET /v1/tickets/?status=INVALID → 400 Bad Request
|
||||
✅ GET /v1/sla/violations → 200 OK (sin error timezone)
|
||||
✅ POST /v1/client-profile/ → 201 Created (con UUID)
|
||||
```
|
||||
|
||||
#### 3. UI/UX
|
||||
```
|
||||
✅ Tabla responsiva con overflow-x-auto
|
||||
✅ Sticky headers funcionan en scroll vertical
|
||||
✅ Hover effects con transiciones suaves
|
||||
✅ Badges con colores semánticos correctos
|
||||
✅ Toggle de tenants actualiza UI instantáneamente
|
||||
```
|
||||
|
||||
### Casos de Prueba Manual
|
||||
|
||||
**Escenario 1: Usuario filtra tickets urgentes**
|
||||
1. Usuario accede a módulo de tickets
|
||||
2. Selecciona prioridad "Urgente" en dropdown
|
||||
3. Sistema recarga automáticamente
|
||||
4. Solo se muestran tickets con prioridad URGENT
|
||||
5. URL refleja filtro: `/tickets/?skip=0&limit=100&priority=URGENT`
|
||||
|
||||
**Resultado:** ✅ Exitoso
|
||||
|
||||
**Escenario 2: Administrador desactiva tenant**
|
||||
1. Admin accede a gestión de tenants
|
||||
2. Hace clic en toggle de un tenant activo
|
||||
3. Toggle cambia a gris, estado actualiza a "inactive"
|
||||
4. Toast muestra "Tenant desactivado"
|
||||
5. Cambio persiste en base de datos
|
||||
|
||||
**Resultado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migraciones de Base de Datos
|
||||
|
||||
### Migración: `fix_client_profiles_timestamps`
|
||||
|
||||
**Propósito:** Agregar defaults de PostgreSQL para campos temporales
|
||||
|
||||
**SQL generado:**
|
||||
```sql
|
||||
-- Upgrade
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN created_at SET DEFAULT now();
|
||||
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN updated_at SET DEFAULT now();
|
||||
|
||||
-- Downgrade (rollback)
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN created_at DROP DEFAULT;
|
||||
|
||||
ALTER TABLE client_profiles
|
||||
ALTER COLUMN updated_at DROP DEFAULT;
|
||||
```
|
||||
|
||||
**Ejecución:**
|
||||
```bash
|
||||
# Aplicar migración
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# Verificar
|
||||
docker-compose exec backend alembic current
|
||||
# Output: fix_client_timestamps (head)
|
||||
```
|
||||
|
||||
**Impacto:** 0 downtime, no modifica datos existentes
|
||||
|
||||
---
|
||||
|
||||
## 📦 Despliegue
|
||||
|
||||
### Pasos para Producción
|
||||
|
||||
1. **Backup de base de datos:**
|
||||
```bash
|
||||
docker-compose exec postgres pg_dump -U postgres servicemanager > backup_pre_v1.8.0.sql
|
||||
```
|
||||
|
||||
2. **Pull del código:**
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v1.8.0
|
||||
```
|
||||
|
||||
3. **Rebuild de servicios modificados:**
|
||||
```bash
|
||||
docker-compose build backend frontend-internal
|
||||
```
|
||||
|
||||
4. **Aplicar migraciones:**
|
||||
```bash
|
||||
docker-compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
5. **Restart de servicios:**
|
||||
```bash
|
||||
docker-compose restart backend frontend-internal
|
||||
```
|
||||
|
||||
6. **Verificar health checks:**
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Expected: {"status": "healthy"}
|
||||
```
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
En caso de problemas críticos:
|
||||
|
||||
```bash
|
||||
# 1. Volver al código anterior
|
||||
git checkout v1.7.1
|
||||
|
||||
# 2. Rollback de migración
|
||||
docker-compose exec backend alembic downgrade -1
|
||||
|
||||
# 3. Rebuild y restart
|
||||
docker-compose build backend frontend-internal
|
||||
docker-compose restart backend frontend-internal
|
||||
|
||||
# 4. Restaurar backup si es necesario
|
||||
docker-compose exec -T postgres psql -U postgres servicemanager < backup_pre_v1.8.0.sql
|
||||
```
|
||||
|
||||
**Tiempo estimado de rollback:** < 5 minutos
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Lecciones Aprendidas
|
||||
|
||||
### 1. Timezone Handling
|
||||
**Problema:** Comparaciones entre timestamps naive y aware causan TypeError.
|
||||
|
||||
**Solución:** Siempre usar `datetime.now(timezone.utc)` y convertir timestamps de BD con `.replace(tzinfo=timezone.utc)`.
|
||||
|
||||
**Best Practice:**
|
||||
```python
|
||||
# ❌ EVITAR
|
||||
now = datetime.now() # Naive, depende de servidor
|
||||
|
||||
# ✅ USAR
|
||||
now = datetime.now(timezone.utc) # Aware, consistente
|
||||
```
|
||||
|
||||
### 2. SQLAlchemy Eager Loading
|
||||
**Problema:** N+1 queries degradan rendimiento significativamente.
|
||||
|
||||
**Solución:** Usar `selectinload()` para cargar relaciones en una sola query.
|
||||
|
||||
**Best Practice:**
|
||||
```python
|
||||
# ❌ EVITAR
|
||||
tickets = await db.execute(select(Ticket))
|
||||
for ticket in tickets:
|
||||
print(ticket.user.email) # Query por cada ticket
|
||||
|
||||
# ✅ USAR
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
tickets = await db.execute(query)
|
||||
```
|
||||
|
||||
### 3. Svelte Reactivity
|
||||
**Problema:** Cambios en objetos dentro de arrays no disparan re-render.
|
||||
|
||||
**Solución:** Usar spread operator para crear nuevo array referencia.
|
||||
|
||||
**Best Practice:**
|
||||
```javascript
|
||||
// ❌ EVITAR
|
||||
tenant.status = 'active';
|
||||
// No re-render
|
||||
|
||||
// ✅ USAR
|
||||
tenant.status = 'active';
|
||||
tenants = [...tenants]; // Crea nueva referencia
|
||||
```
|
||||
|
||||
### 4. API Query String Construction
|
||||
**Problema:** Construcción manual de URLs puede causar codificación incorrecta.
|
||||
|
||||
**Solución:** Usar `URLSearchParams` nativo de JavaScript.
|
||||
|
||||
**Best Practice:**
|
||||
```javascript
|
||||
// ❌ EVITAR
|
||||
let url = '/tickets/?status=' + status + '&priority=' + priority;
|
||||
|
||||
// ✅ USAR
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (priority) params.append('priority', priority);
|
||||
const url = `/tickets/?${params.toString()}`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentación Actualizada
|
||||
|
||||
### Nuevos Parámetros de API
|
||||
|
||||
**Endpoint:** `GET /v1/tickets/`
|
||||
|
||||
**Parámetros query:**
|
||||
- `skip` (int): Offset para paginación (default: 0)
|
||||
- `limit` (int): Cantidad máxima de resultados (default: 100)
|
||||
- `status` (string, optional): Filtrar por estado
|
||||
- Valores válidos: `NEW`, `IN_PROGRESS`, `WAITING_CUSTOMER`, `RESOLVED`, `CLOSED`, `REOPENED`
|
||||
- `priority` (string, optional): Filtrar por prioridad
|
||||
- Valores válidos: `LOW`, `MEDIUM`, `HIGH`, `URGENT`
|
||||
|
||||
**Ejemplo de uso:**
|
||||
```bash
|
||||
# Tickets nuevos de alta prioridad
|
||||
GET /v1/tickets/?status=NEW&priority=HIGH
|
||||
|
||||
# Solo tickets urgentes
|
||||
GET /v1/tickets/?priority=URGENT
|
||||
|
||||
# Tickets en progreso (paginados)
|
||||
GET /v1/tickets/?status=IN_PROGRESS&skip=20&limit=20
|
||||
```
|
||||
|
||||
**Respuestas:**
|
||||
- `200 OK`: Lista de tickets filtrados
|
||||
- `400 Bad Request`: Parámetro inválido
|
||||
- `401 Unauthorized`: Token expirado/inválido
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Consideraciones de Seguridad
|
||||
|
||||
### Validación de Inputs
|
||||
✅ **Implementado:** Todos los filtros validan contra enums definidos.
|
||||
|
||||
```python
|
||||
# Previene SQL injection y valores arbitrarios
|
||||
try:
|
||||
status_enum = TicketStatus[status.upper()]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
```
|
||||
|
||||
### Multi-tenancy
|
||||
✅ **Mantenido:** Todos los endpoints filtran por `tenant_id`.
|
||||
|
||||
```python
|
||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
||||
```
|
||||
|
||||
### RBAC (Role-Based Access Control)
|
||||
✅ **Preservado:** Clientes solo ven sus propios tickets.
|
||||
|
||||
```python
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Próximos Pasos (v1.9.0)
|
||||
|
||||
### Funcionalidades Planificadas
|
||||
1. **Paginación completa:**
|
||||
- Botones prev/next en frontend
|
||||
- Indicador de página actual
|
||||
- Total de registros
|
||||
|
||||
2. **Filtros adicionales:**
|
||||
- Búsqueda por texto (subject/description)
|
||||
- Filtro por rango de fechas
|
||||
- Filtro por categoría
|
||||
|
||||
3. **Exportación de datos:**
|
||||
- Exportar tickets a CSV
|
||||
- Exportar a PDF con filtros aplicados
|
||||
|
||||
4. **Optimizaciones:**
|
||||
- Caché de categorías/sistemas en localStorage
|
||||
- Lazy loading de imágenes/avatares
|
||||
- Debounce en búsquedas de texto
|
||||
|
||||
### Mejoras Técnicas
|
||||
1. Tests automatizados (pytest + Svelte Testing Library)
|
||||
2. Documentación OpenAPI más completa
|
||||
3. Metrics con Prometheus
|
||||
4. Logging estructurado mejorado
|
||||
|
||||
---
|
||||
|
||||
## 👥 Créditos
|
||||
|
||||
**Desarrollador:** Equipo de Desarrollo Aduanasoft
|
||||
**Revisión Técnica:** GitHub Copilot
|
||||
**QA:** Testing manual interno
|
||||
**Arquitectura:** Clean Architecture + Domain-Driven Design
|
||||
|
||||
---
|
||||
|
||||
## 📞 Soporte
|
||||
|
||||
Para reportar issues o consultas sobre esta versión:
|
||||
- **Email:** dev@aduanasoft.com
|
||||
- **Sistema:** ServiceManagerWeb Internal
|
||||
- **Versión:** 1.8.0
|
||||
- **Fecha de release:** 17/02/2026
|
||||
|
||||
---
|
||||
|
||||
## 🏁 Conclusión
|
||||
|
||||
La versión 1.8.0 consolida el sistema como **MVP production-ready**, con:
|
||||
- ✅ Sistema de filtros totalmente funcional
|
||||
- ✅ UI optimizada para mayor densidad de información
|
||||
- ✅ 0 errores críticos en endpoints principales
|
||||
- ✅ Codebase más limpio (-1633 líneas)
|
||||
- ✅ Mejor rendimiento en queries (95% reducción)
|
||||
|
||||
**Estado del proyecto:** Listo para despliegue en producción.
|
||||
|
||||
---
|
||||
|
||||
*Documento generado automáticamente para ServiceManagerWeb v1.8.0*
|
||||
*© 2026 Aduanasoft - Todos los derechos reservados*
|
||||
@@ -1,343 +0,0 @@
|
||||
# Optimizaciones de Rendimiento - ServiceManagerWeb
|
||||
|
||||
## 🎯 Estado Actual
|
||||
El sistema funciona correctamente, pero podemos implementar mejoras para hacerlo más rápido.
|
||||
|
||||
## 🚀 Optimizaciones Implementables
|
||||
|
||||
### 1. **Backend - Base de Datos** (ALTO IMPACTO)
|
||||
|
||||
#### A. Aumentar Pool de Conexiones
|
||||
**Archivo**: `backend/app/core/database.py`
|
||||
|
||||
```python
|
||||
# Actual
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=5, # ← Aumentar a 20
|
||||
max_overflow=10, # ← Aumentar a 30
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# Optimizado
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=20, # Más conexiones concurrentes
|
||||
max_overflow=30, # Más overflow para picos
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 30-50% más rápido en endpoints con DB
|
||||
|
||||
---
|
||||
|
||||
#### B. Agregar Índices Faltantes
|
||||
**Ejecutar migrations**:
|
||||
|
||||
```sql
|
||||
-- Índices para queries frecuentes
|
||||
CREATE INDEX CONCURRENTLY idx_tickets_status_tenant ON tickets(status, tenant_id);
|
||||
CREATE INDEX CONCURRENTLY idx_tickets_assigned_to ON tickets(assigned_to);
|
||||
CREATE INDEX CONCURRENTLY idx_tickets_created_at ON tickets(created_at DESC);
|
||||
CREATE INDEX CONCURRENTLY idx_users_email_tenant ON users(email, tenant_id);
|
||||
CREATE INDEX CONCURRENTLY idx_audit_logs_tenant_created ON audit_logs(tenant_id, created_at DESC);
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 40-70% más rápido en listados y búsquedas
|
||||
|
||||
---
|
||||
|
||||
### 2. **Backend - Caché con Redis** (ALTO IMPACTO)
|
||||
|
||||
#### Crear servicio de caché
|
||||
**Nuevo archivo**: `backend/app/core/cache.py`
|
||||
|
||||
```python
|
||||
"""Redis caching service"""
|
||||
from redis import asyncio as aioredis
|
||||
from typing import Optional, Any
|
||||
import json
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
class CacheService:
|
||||
def __init__(self):
|
||||
self.redis = None
|
||||
|
||||
async def connect(self):
|
||||
self.redis = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
value = await self.redis.get(key)
|
||||
return json.loads(value) if value else None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int = 300):
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
await self.redis.setex(key, ttl, json.dumps(value))
|
||||
|
||||
async def delete(self, key: str):
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
await self.redis.delete(key)
|
||||
|
||||
cache = CacheService()
|
||||
```
|
||||
|
||||
#### Usar en endpoints frecuentes:
|
||||
|
||||
```python
|
||||
# Ejemplo: Cachear listado de categorías
|
||||
@router.get("/categories")
|
||||
async def list_categories(db: AsyncSession = Depends(get_db)):
|
||||
cache_key = f"categories:tenant:{tenant_id}"
|
||||
|
||||
# Intentar cache
|
||||
cached = await cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Si no hay cache, query DB
|
||||
result = await db.execute(select(Category))
|
||||
categories = result.scalars().all()
|
||||
|
||||
# Guardar en cache por 5 minutos
|
||||
await cache.set(cache_key, categories, ttl=300)
|
||||
return categories
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 80-95% más rápido en datos que no cambian frecuentemente
|
||||
|
||||
---
|
||||
|
||||
### 3. **Backend - Uvicorn Workers** (MEDIO IMPACTO)
|
||||
|
||||
#### Actualizar Dockerfile
|
||||
**Archivo**: `docker/Dockerfile.backend`
|
||||
|
||||
```dockerfile
|
||||
# Cambiar la última línea de:
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
# A modo producción:
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
```
|
||||
|
||||
**Nota**: Quitar `--reload` en producción (consume recursos).
|
||||
|
||||
**Impacto**: ⚡ 2-4x más throughput (requests por segundo)
|
||||
|
||||
---
|
||||
|
||||
### 4. **Frontend - Code Splitting y Lazy Loading** (MEDIO IMPACTO)
|
||||
|
||||
#### Configurar lazy loading en rutas
|
||||
**Archivo**: `frontend-internal/src/routes/+layout.svelte`
|
||||
|
||||
```typescript
|
||||
// En lugar de importar todo:
|
||||
import HeavyComponent from '$lib/components/HeavyComponent.svelte';
|
||||
|
||||
// Usar dynamic imports:
|
||||
const HeavyComponent = () => import('$lib/components/HeavyComponent.svelte');
|
||||
```
|
||||
|
||||
#### Optimizar build de Vite
|
||||
**Archivo**: `frontend-internal/vite.config.js`
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor': ['svelte', 'svelte/store'],
|
||||
'charts': ['chart.js'], // Si usas charts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 40-60% más rápido el load inicial del frontend
|
||||
|
||||
---
|
||||
|
||||
### 5. **Queries SQL - Eager Loading** (ALTO IMPACTO)
|
||||
|
||||
#### Usar selectinload para relaciones
|
||||
**Ejemplo en endpoints de tickets**:
|
||||
|
||||
```python
|
||||
# Antes (N+1 queries)
|
||||
query = select(Ticket).where(Ticket.tenant_id == tenant_id)
|
||||
|
||||
# Después (1 query con joins)
|
||||
query = select(Ticket).options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.assigned_user),
|
||||
selectinload(Ticket.comments)
|
||||
).where(Ticket.tenant_id == tenant_id)
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 50-80% más rápido al traer relaciones
|
||||
|
||||
---
|
||||
|
||||
### 6. **Logging en Producción** (MEDIO IMPACTO)
|
||||
|
||||
#### Reducir logging en producción
|
||||
**Archivo**: `.env`
|
||||
|
||||
```bash
|
||||
# Development
|
||||
DEBUG=true
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Production (cambiar a)
|
||||
DEBUG=false
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
**Impacto**: ⚡ 10-15% menos overhead
|
||||
|
||||
---
|
||||
|
||||
### 7. **Docker - Recursos** (BAJO IMPACTO)
|
||||
|
||||
#### Asignar más recursos en docker-compose
|
||||
**Archivo**: `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
# ... config existente
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
|
||||
postgres:
|
||||
# ... config existente
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Prioridades de Implementación
|
||||
|
||||
### **Fase 1 - Quick Wins** (1-2 horas)
|
||||
1. ✅ Aumentar pool de DB
|
||||
2. ✅ Quitar `--reload` en producción
|
||||
3. ✅ Reducir logging (LOG_LEVEL=WARNING)
|
||||
|
||||
**Ganancia esperada**: 30-40% mejora general
|
||||
|
||||
---
|
||||
|
||||
### **Fase 2 - Optimizaciones Importantes** (2-4 horas)
|
||||
1. ✅ Agregar índices de DB
|
||||
2. ✅ Implementar caché con Redis
|
||||
3. ✅ Eager loading en queries complejas
|
||||
|
||||
**Ganancia esperada**: 50-70% mejora en endpoints cacheables
|
||||
|
||||
---
|
||||
|
||||
### **Fase 3 - Optimizaciones Avanzadas** (4-8 horas)
|
||||
1. ✅ Uvicorn workers múltiples
|
||||
2. ✅ Frontend code splitting
|
||||
3. ✅ Optimización de queries lentas
|
||||
|
||||
**Ganancia esperada**: 2-3x mejora en throughput total
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Comandos Rápidos
|
||||
|
||||
### Implementar Fase 1 (copiar y ejecutar):
|
||||
|
||||
```bash
|
||||
# 1. Editar database.py (aumentar pools)
|
||||
# Ver sección 1.A arriba
|
||||
|
||||
# 2. Editar Dockerfile.backend (quitar reload)
|
||||
# Ver sección 3 arriba
|
||||
|
||||
# 3. Editar .env
|
||||
echo "DEBUG=false" >> .env
|
||||
echo "LOG_LEVEL=WARNING" >> .env
|
||||
|
||||
# 4. Reiniciar servicios
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Monitorear Mejoras
|
||||
|
||||
```bash
|
||||
# Medir tiempo de respuesta ANTES
|
||||
curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF'
|
||||
time_total: %{time_total}s\n
|
||||
EOF
|
||||
|
||||
# Implementar optimizaciones...
|
||||
|
||||
# Medir tiempo de respuesta DESPUÉS
|
||||
curl -w "@-" -o /dev/null -s http://localhost:8000/v1/tickets <<'EOF'
|
||||
time_total: %{time_total}s\n
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Resultados Esperados
|
||||
|
||||
| Métrica | Actual | Optimizado | Mejora |
|
||||
|---------|--------|------------|--------|
|
||||
| Login | ~300ms | ~100ms | 3x |
|
||||
| Listar tickets | ~500ms | ~150ms | 3.3x |
|
||||
| Crear ticket | ~400ms | ~200ms | 2x |
|
||||
| Dashboard SLA | ~800ms | ~200ms | 4x (con cache) |
|
||||
| Load frontend | ~2s | ~800ms | 2.5x |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Mejores Prácticas Adicionales
|
||||
|
||||
1. **Paginación siempre**: Nunca devolver listados sin límite
|
||||
2. **Índices compuestos**: Para queries con múltiples WHERE
|
||||
3. **Redis para sesiones**: Mover JWT refresh tokens a Redis
|
||||
4. **CDN para assets**: Servir JS/CSS desde CDN en producción
|
||||
5. **HTTP/2**: Configurar Nginx con HTTP/2
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
- **Redis ya está corriendo**: Solo falta implementar CacheService
|
||||
- **No optimizar prematuramente**: Medir primero, optimizar después
|
||||
- **Testing**: Probar cada optimización para evitar regresiones
|
||||
- **Monitoring**: Agregar métricas con Prometheus/Grafana (opcional)
|
||||
|
||||
---
|
||||
|
||||
¿Quieres que implemente alguna de estas optimizaciones ahora?
|
||||
@@ -57,6 +57,7 @@ class AuditLogResponse(AuditLogBase):
|
||||
|
||||
class SecurityThreatPattern(BaseModel):
|
||||
"""Patrón de amenaza detectado."""
|
||||
id: str = Field(description="ID único de la amenaza (pattern_id)")
|
||||
type: str = Field(description="Tipo de amenaza (brute_force, privilege_escalation, etc.)")
|
||||
severity: str = Field(description="Severidad: low, medium, high, critical")
|
||||
description: str = Field(description="Descripción de la amenaza")
|
||||
@@ -65,7 +66,7 @@ class SecurityThreatPattern(BaseModel):
|
||||
affected_users: list[str] = Field(default=[], description="Usuarios afectados")
|
||||
first_seen: datetime = Field(description="Primera ocurrencia")
|
||||
last_seen: datetime = Field(description="Última ocurrencia")
|
||||
recommendations: list[str] = Field(default=[], description="Recomendaciones de acción")
|
||||
recommended_action: str = Field(default="", description="Acción recomendada")
|
||||
|
||||
|
||||
class SecurityAnalysisResponse(BaseModel):
|
||||
|
||||
@@ -123,7 +123,7 @@ def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"status": status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": group['logs'][0].ip_address,
|
||||
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
|
||||
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
@@ -207,7 +207,7 @@ def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": log.ip_address,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
|
||||
@@ -152,38 +152,47 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
|
||||
threat_patterns = []
|
||||
|
||||
if failed_logins >= 5:
|
||||
affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
pattern_id="brute_force_attempt",
|
||||
id="brute_force_attempt",
|
||||
type="brute_force",
|
||||
description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h",
|
||||
severity="high" if failed_logins >= 20 else "medium",
|
||||
occurrences=failed_logins,
|
||||
first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
|
||||
last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now),
|
||||
affected_resources=[str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address][:5],
|
||||
affected_ips=list(set(affected_ips_list))[:5],
|
||||
affected_users=[],
|
||||
recommended_action="Considerar bloquear IPs con múltiples fallos"
|
||||
))
|
||||
|
||||
if mass_deletions >= 10:
|
||||
deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
pattern_id="mass_deletion",
|
||||
id="mass_deletion",
|
||||
type="mass_deletion",
|
||||
description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h",
|
||||
severity="critical" if mass_deletions >= 50 else "high",
|
||||
occurrences=mass_deletions,
|
||||
first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now),
|
||||
last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now),
|
||||
affected_resources=[log.resource_type for log in logs if '.delete' in log.action][:5],
|
||||
affected_ips=[],
|
||||
affected_users=list(set(deleting_users))[:5],
|
||||
recommended_action="Revisar qué usuarios están eliminando recursos"
|
||||
))
|
||||
|
||||
if privilege_changes >= 3:
|
||||
affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values]
|
||||
threat_patterns.append(SecurityThreatPattern(
|
||||
pattern_id="suspicious_privilege_changes",
|
||||
id="suspicious_privilege_changes",
|
||||
type="privilege_escalation",
|
||||
description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h",
|
||||
severity="high",
|
||||
occurrences=privilege_changes,
|
||||
first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
|
||||
last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now),
|
||||
affected_resources=[log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values][:5],
|
||||
affected_ips=[],
|
||||
affected_users=list(set(affected_users_list))[:5],
|
||||
recommended_action="Auditar cambios de roles recientes"
|
||||
))
|
||||
|
||||
|
||||
1033
backend/app/api/v1/endpoints/audit_backup.py
Normal file
1033
backend/app/api/v1/endpoints/audit_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -91,7 +91,7 @@ async def get_sla_dashboard(
|
||||
days=days
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
period_start = now - timedelta(days=days)
|
||||
|
||||
# Usar func.now() para comparaciones en SQL (evita timezone issues)
|
||||
@@ -357,7 +357,7 @@ async def get_sla_violations(
|
||||
sla_type=sla_type
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db_now = func.now()
|
||||
|
||||
# Base query con carga de relaciones
|
||||
@@ -417,18 +417,16 @@ async def get_sla_violations(
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Aplicar paginación
|
||||
query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit)
|
||||
|
||||
# Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
# Formatear response
|
||||
violations = []
|
||||
for ticket in tickets:
|
||||
# Asegurar que los datetimes de BD sean timezone-aware
|
||||
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
|
||||
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
|
||||
# Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE)
|
||||
sla_response_due = ticket.sla_response_due
|
||||
sla_resolution_due = ticket.sla_resolution_due
|
||||
|
||||
# Determinar tipo de violación
|
||||
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
|
||||
@@ -479,10 +477,16 @@ async def get_sla_violations(
|
||||
resolved_at=ticket.resolved_at
|
||||
))
|
||||
|
||||
# Ordenar por tiempo vencido (de mayor a menor)
|
||||
violations.sort(key=lambda v: v.hours_overdue, reverse=True)
|
||||
|
||||
# Aplicar paginación en Python
|
||||
paginated_violations = violations[skip:skip + limit]
|
||||
|
||||
total_pages = (total + limit - 1) // limit
|
||||
|
||||
return SLAViolationsListResponse(
|
||||
violations=violations,
|
||||
violations=paginated_violations,
|
||||
total=total,
|
||||
page=(skip // limit) + 1,
|
||||
per_page=limit,
|
||||
@@ -515,13 +519,16 @@ async def get_tickets_at_risk(
|
||||
threshold=threshold
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db_now = func.now()
|
||||
threshold_decimal = threshold / 100.0
|
||||
|
||||
# Query para tickets en riesgo
|
||||
# Query para tickets en riesgo con relaciones precargadas
|
||||
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
|
||||
query = select(Ticket).where(
|
||||
query = select(Ticket).options(
|
||||
selectinload(Ticket.assigned_to_user),
|
||||
selectinload(Ticket.category)
|
||||
).where(
|
||||
and_(
|
||||
Ticket.tenant_id == current_tenant.id,
|
||||
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
@@ -576,14 +583,15 @@ async def get_tickets_at_risk(
|
||||
else:
|
||||
continue
|
||||
|
||||
# Normalizar created_at a timezone-naive para evitar errores de comparación
|
||||
created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at
|
||||
|
||||
time_remaining = (due_at - now).total_seconds() / 3600
|
||||
total_time = (due_at - ticket.created_at).total_seconds() / 3600
|
||||
total_time = (due_at - created_at).total_seconds() / 3600
|
||||
elapsed_time = total_time - time_remaining
|
||||
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
|
||||
|
||||
# Cargar relaciones
|
||||
await db.refresh(ticket, ['assigned_to', 'category'])
|
||||
|
||||
# Las relaciones ya están cargadas por selectinload
|
||||
at_risk_tickets.append(SLATicketAtRisk(
|
||||
ticket=TicketBasicInfo(
|
||||
id=ticket.id,
|
||||
@@ -599,11 +607,11 @@ async def get_tickets_at_risk(
|
||||
sla_resolution_hours=ticket.category.sla_resolution_hours
|
||||
) if ticket.category else None,
|
||||
assigned_to=UserBasicInfo(
|
||||
id=ticket.assigned_to.id,
|
||||
first_name=ticket.assigned_to.first_name,
|
||||
last_name=ticket.assigned_to.last_name,
|
||||
email=ticket.assigned_to.email
|
||||
) if ticket.assigned_to else None,
|
||||
id=ticket.assigned_to_user.id,
|
||||
first_name=ticket.assigned_to_user.first_name,
|
||||
last_name=ticket.assigned_to_user.last_name,
|
||||
email=ticket.assigned_to_user.email
|
||||
) if ticket.assigned_to_user else None,
|
||||
sla_type=sla_type,
|
||||
sla_due_at=due_at,
|
||||
time_remaining_hours=time_remaining,
|
||||
|
||||
1072
backend/app/api/v1/endpoints/tickets_backup.py
Normal file
1072
backend/app/api/v1/endpoints/tickets_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
193
backend/scripts/README_SECURITY_TESTS.md
Normal file
193
backend/scripts/README_SECURITY_TESTS.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Scripts de Prueba de Seguridad
|
||||
|
||||
Scripts para generar datos de prueba para el análisis de seguridad.
|
||||
|
||||
## 📋 Scripts Disponibles
|
||||
|
||||
### 1. `generate_security_test_data.py`
|
||||
|
||||
Genera un conjunto completo de logs de auditoría para probar todas las funcionalidades del análisis de seguridad.
|
||||
|
||||
#### Uso
|
||||
|
||||
```bash
|
||||
# Asegúrate de estar en el entorno virtual
|
||||
cd backend
|
||||
python scripts/generate_security_test_data.py
|
||||
```
|
||||
|
||||
#### Qué Genera
|
||||
|
||||
- **25 intentos fallidos de login** → Amenaza HIGH de fuerza bruta
|
||||
- **55 eliminaciones masivas** → Amenaza CRITICAL
|
||||
- **5 cambios de privilegios** → Amenaza HIGH de escalación de privilegios
|
||||
- **20 logs normales** → Actividad regular para contexto
|
||||
|
||||
#### Limpiar Datos de Prueba
|
||||
|
||||
```bash
|
||||
python scripts/generate_security_test_data.py cleanup
|
||||
```
|
||||
|
||||
Esto eliminará **TODOS** los logs de auditoría de las últimas 24 horas.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Escenarios de Prueba
|
||||
|
||||
### Escenario 1: Sistema Limpio (Sin Amenazas)
|
||||
|
||||
```bash
|
||||
# Limpiar todos los logs
|
||||
python scripts/generate_security_test_data.py cleanup
|
||||
```
|
||||
|
||||
**Resultado esperado:**
|
||||
- Dashboard con todos los contadores en 0
|
||||
- Tab "Crítico" vacío
|
||||
- Mensaje: "Sistema Seguro"
|
||||
|
||||
---
|
||||
|
||||
### Escenario 2: Solo Amenazas Leves
|
||||
|
||||
Modifica el script para generar solo 6 intentos fallidos (MEDIUM severity):
|
||||
|
||||
```python
|
||||
# En generate_security_test_data.py, línea ~70
|
||||
for i in range(6): # Cambiar de 25 a 6
|
||||
```
|
||||
|
||||
**Resultado esperado:**
|
||||
- 1 amenaza MEDIUM en tab correspondiente
|
||||
- Tab "Crítico" vacío
|
||||
- Nivel de riesgo: LOW o MEDIUM
|
||||
|
||||
---
|
||||
|
||||
### Escenario 3: Amenazas Críticas
|
||||
|
||||
Ejecuta el script completo:
|
||||
|
||||
```bash
|
||||
python scripts/generate_security_test_data.py
|
||||
```
|
||||
|
||||
**Resultado esperado:**
|
||||
- 1 amenaza CRÍTICA (eliminaciones masivas)
|
||||
- 2 amenazas HIGH (login fallidos + privilegios)
|
||||
- Tab "Crítico" con 1 amenaza
|
||||
- Nivel de riesgo: CRITICAL
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Umbrales de Detección
|
||||
|
||||
| Tipo de Amenaza | Umbral Detección | Severidades |
|
||||
|-----------------|------------------|-------------|
|
||||
| **Fuerza Bruta** | ≥5 intentos fallidos | MEDIUM (5-19), HIGH (≥20) |
|
||||
| **Eliminaciones Masivas** | ≥10 eliminaciones | HIGH (10-49), **CRITICAL (≥50)** |
|
||||
| **Cambios de Privilegios** | ≥3 cambios de rol | HIGH (siempre) |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verificar Resultados
|
||||
|
||||
1. **Accede al panel de auditoría**: http://localhost:3001/audit/security
|
||||
|
||||
2. **Verifica los contadores del dashboard:**
|
||||
- Nivel de Riesgo
|
||||
- Amenazas Detectadas
|
||||
- Intentos Fallidos
|
||||
- IPs Sospechosas
|
||||
- Acciones Críticas
|
||||
|
||||
3. **Prueba los tabs:**
|
||||
- Todas: Debe mostrar amenazas activas
|
||||
- Crítico: Solo amenazas critical (si hay)
|
||||
- High: Amenazas de alta severidad
|
||||
- Medium: Amenazas de severidad media
|
||||
- Low: Amenazas de baja severidad
|
||||
- Resueltas: Amenazas marcadas como resueltas
|
||||
|
||||
4. **Prueba la búsqueda:**
|
||||
- Busca por IP: `192.168.1.100`
|
||||
- Busca por descripción: `intentos fallidos`
|
||||
- Busca por tipo: `brute_force`
|
||||
|
||||
5. **Prueba los filtros:**
|
||||
- Filtra por tipo de amenaza
|
||||
- Combina búsqueda + filtro
|
||||
|
||||
6. **Prueba las acciones:**
|
||||
- Selecciona múltiples amenazas
|
||||
- Resuelve en batch
|
||||
- Marca como resuelta individualmente
|
||||
- Reabre amenazas resueltas
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Advertencias
|
||||
|
||||
- **NO ejecutar en producción**: Estos scripts son SOLO para desarrollo/testing
|
||||
- **Los datos son ficticios**: IPs, usuarios y acciones son simulados
|
||||
- **Cleanup elimina TODO**: El comando cleanup elimina TODOS los logs de las últimas 24h, no solo los de prueba
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Error: "No se encontró ningún tenant"
|
||||
```bash
|
||||
# Ejecuta las migraciones
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Error: "No se encontró ningún usuario"
|
||||
```bash
|
||||
# Crea un usuario de prueba
|
||||
python scripts/create_test_user.py
|
||||
```
|
||||
|
||||
### La página no muestra amenazas
|
||||
- Verifica que el backend esté corriendo: `uvicorn app.main:app --reload`
|
||||
- Revisa la consola del navegador para errores
|
||||
- Verifica que los logs se crearon: `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hours';`
|
||||
|
||||
### Las fechas no son de hoy
|
||||
- Los logs se crean con timestamps aleatorios en las últimas 24h
|
||||
- Si todos tienen la misma fecha, es porque se generaron en el mismo segundo (normal)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Personalizar Generación
|
||||
|
||||
Para crear escenarios personalizados, edita `generate_security_test_data.py`:
|
||||
|
||||
```python
|
||||
# Cambiar cantidad de intentos fallidos
|
||||
for i in range(50): # Más intentos = mayor severidad
|
||||
|
||||
# Cambiar IPs sospechosas
|
||||
suspicious_ips = ["1.2.3.4", "5.6.7.8"]
|
||||
|
||||
# Cambiar período temporal
|
||||
time_offset = timedelta(hours=12) # Todos en las últimas 12h
|
||||
|
||||
# Agregar más tipos de amenazas
|
||||
# Agrega nuevos bloques de generación siguiendo el patrón
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Flujo Recomendado de Prueba
|
||||
|
||||
1. **Limpia el sistema**: `python scripts/generate_security_test_data.py cleanup`
|
||||
2. **Verifica sistema limpio**: Accede a la página, debe estar vacía
|
||||
3. **Genera datos completos**: `python scripts/generate_security_test_data.py`
|
||||
4. **Prueba todas las funcionalidades**: tabs, filtros, búsqueda, acciones
|
||||
5. **Marca algunas como resueltas**: Prueba el flujo de resolución
|
||||
6. **Verifica tab "Resueltas"**: Confirma que aparecen ahí
|
||||
7. **Reabre algunas**: Prueba el flujo de reapertura
|
||||
8. **Limpia al finalizar**: `python scripts/generate_security_test_data.py cleanup`
|
||||
240
backend/scripts/generate_security_test_data.py
Normal file
240
backend/scripts/generate_security_test_data.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Script para generar datos de prueba de seguridad en logs de auditoría.
|
||||
Esto permite probar la funcionalidad de análisis de seguridad con diferentes tipos de amenazas.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
import random
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
async def generate_test_data():
|
||||
"""Genera logs de auditoría de prueba para análisis de seguridad."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Obtener tenant y usuarios de prueba
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||
return
|
||||
|
||||
user_result = await db.execute(select(User).where(User.tenant_id == tenant.id).limit(1))
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
print("❌ No se encontró ningún usuario. Crea un usuario primero.")
|
||||
return
|
||||
|
||||
print(f"✅ Usando tenant: {tenant.name}")
|
||||
print(f"✅ Usando usuario: {user.email}")
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# IPs de prueba
|
||||
suspicious_ips = [
|
||||
"192.168.1.100",
|
||||
"10.0.0.50",
|
||||
"172.16.0.10",
|
||||
"203.0.113.42",
|
||||
"198.51.100.88"
|
||||
]
|
||||
|
||||
logs_created = 0
|
||||
|
||||
# ============================================
|
||||
# 1. GENERAR INTENTOS FALLIDOS DE LOGIN (Fuerza Bruta)
|
||||
# ============================================
|
||||
print("🔐 Generando intentos fallidos de login...")
|
||||
|
||||
# Generar 25 intentos fallidos (esto hará que sea HIGH severity)
|
||||
for i in range(25):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action="user.login_failed",
|
||||
resource_type="auth",
|
||||
resource_id=None,
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"reason": "invalid_credentials", "username": f"test_user_{i}"},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {25} intentos fallidos de login (HIGH severity)")
|
||||
|
||||
# ============================================
|
||||
# 2. GENERAR ELIMINACIONES MASIVAS (CRITICAL)
|
||||
# ============================================
|
||||
print("🗑️ Generando eliminaciones masivas...")
|
||||
|
||||
resources = ["ticket", "comment", "attachment", "category", "user"]
|
||||
|
||||
# Generar 55 eliminaciones (esto hará que sea CRITICAL severity)
|
||||
for i in range(55):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
resource = random.choice(resources)
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action=f"{resource}.delete",
|
||||
resource_type=resource,
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"deleted_by": user.email},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creadas {55} eliminaciones masivas (CRITICAL severity)")
|
||||
|
||||
# ============================================
|
||||
# 3. GENERAR CAMBIOS DE PRIVILEGIOS (HIGH)
|
||||
# ============================================
|
||||
print("👤 Generando cambios de privilegios...")
|
||||
|
||||
roles = ["AGENT", "CLIENT_USER", "AUDITOR", "SUPPORT_MANAGER", "ADMIN"]
|
||||
|
||||
# Generar 5 cambios de rol (esto hará que sea HIGH severity)
|
||||
for i in range(5):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
old_role = random.choice(roles)
|
||||
new_role = random.choice([r for r in roles if r != old_role])
|
||||
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action="user.update",
|
||||
resource_type="user",
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
old_values={"role": old_role},
|
||||
new_values={"role": new_role},
|
||||
metadata={"changed_by": user.email},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {5} cambios de privilegios (HIGH severity)")
|
||||
|
||||
# ============================================
|
||||
# 4. GENERAR LOGS NORMALES (para dar contexto)
|
||||
# ============================================
|
||||
print("📋 Generando logs de actividad normal...")
|
||||
|
||||
normal_actions = [
|
||||
"ticket.create",
|
||||
"ticket.update",
|
||||
"comment.create",
|
||||
"user.login",
|
||||
"ticket.view",
|
||||
]
|
||||
|
||||
for i in range(20):
|
||||
time_offset = timedelta(hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
action = random.choice(normal_actions)
|
||||
|
||||
log = AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
resource_type=action.split('.')[0],
|
||||
resource_id=uuid.uuid4(),
|
||||
ip_address=random.choice(suspicious_ips),
|
||||
user_agent="Mozilla/5.0 (Test Browser)",
|
||||
metadata={"action": "normal_activity"},
|
||||
created_at=now - time_offset
|
||||
)
|
||||
db.add(log)
|
||||
logs_created += 1
|
||||
|
||||
print(f" ✓ Creados {20} logs de actividad normal")
|
||||
|
||||
# Guardar todo
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"✅ GENERACIÓN COMPLETADA")
|
||||
print(f" Total de logs creados: {logs_created}")
|
||||
print()
|
||||
print("📊 Amenazas esperadas en el análisis:")
|
||||
print(" 🔴 1 amenaza CRÍTICA: 55 eliminaciones masivas")
|
||||
print(" 🟠 1 amenaza HIGH: 25 intentos fallidos de login")
|
||||
print(" 🟠 1 amenaza HIGH: 5 cambios de privilegios")
|
||||
print()
|
||||
print("🌐 Accede a la página de seguridad para ver el análisis")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
async def cleanup_test_data():
|
||||
"""Elimina los logs de auditoría de prueba."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant.")
|
||||
return
|
||||
|
||||
# Eliminar logs de las últimas 24 horas
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(hours=24)
|
||||
|
||||
result = await db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.tenant_id == tenant.id,
|
||||
AuditLog.created_at >= cutoff
|
||||
)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
if not logs:
|
||||
print("ℹ️ No hay logs de prueba para eliminar.")
|
||||
return
|
||||
|
||||
for log in logs:
|
||||
await db.delete(log)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"✅ Eliminados {len(logs)} logs de prueba de las últimas 24 horas")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||
print("🧹 Limpiando datos de prueba...")
|
||||
asyncio.run(cleanup_test_data())
|
||||
else:
|
||||
print("🚀 Generando datos de prueba para análisis de seguridad...")
|
||||
print()
|
||||
asyncio.run(generate_test_data())
|
||||
print()
|
||||
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||
print(" python scripts/generate_security_test_data.py cleanup")
|
||||
399
backend/scripts/generate_sla_test_data.py
Normal file
399
backend/scripts/generate_sla_test_data.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Script para generar datos de prueba de SLA Management.
|
||||
Crea tickets con diferentes estados de SLA para probar el dashboard.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
import random
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.system import System
|
||||
|
||||
|
||||
async def generate_sla_test_data():
|
||||
"""Genera tickets de prueba con diferentes estados de SLA."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Obtener tenant y usuarios
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant. Ejecuta las migraciones primero.")
|
||||
return
|
||||
|
||||
# Obtener usuarios
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.tenant_id == tenant.id).limit(5)
|
||||
)
|
||||
users = list(users_result.scalars().all())
|
||||
|
||||
if not users:
|
||||
print("❌ No se encontraron usuarios. Crea usuarios primero.")
|
||||
return
|
||||
|
||||
creator = users[0]
|
||||
agents = users if len(users) > 1 else [creator]
|
||||
|
||||
# Obtener o crear categorías
|
||||
categories_result = await db.execute(
|
||||
select(Category).where(Category.tenant_id == tenant.id)
|
||||
)
|
||||
categories = list(categories_result.scalars().all())
|
||||
|
||||
if not categories:
|
||||
print("📁 Creando categorías de prueba...")
|
||||
category_data = [
|
||||
{"name": "Soporte Técnico", "sla_response_hours": 2, "sla_resolution_hours": 24, "color": "#3B82F6"},
|
||||
{"name": "Facturación", "sla_response_hours": 4, "sla_resolution_hours": 48, "color": "#10B981"},
|
||||
{"name": "Incidente Crítico", "sla_response_hours": 1, "sla_resolution_hours": 8, "color": "#EF4444"},
|
||||
{"name": "Consulta General", "sla_response_hours": 8, "sla_resolution_hours": 72, "color": "#6B7280"},
|
||||
]
|
||||
|
||||
for cat_data in category_data:
|
||||
category = Category(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
name=cat_data["name"],
|
||||
description=f"Categoría de {cat_data['name']}",
|
||||
color=cat_data["color"],
|
||||
sla_response_hours=cat_data["sla_response_hours"],
|
||||
sla_resolution_hours=cat_data["sla_resolution_hours"],
|
||||
is_active=True
|
||||
)
|
||||
db.add(category)
|
||||
categories.append(category)
|
||||
|
||||
await db.commit()
|
||||
print(f" ✓ Creadas {len(categories)} categorías")
|
||||
|
||||
# Obtener o crear sistemas afectados
|
||||
systems_result = await db.execute(
|
||||
select(System).where(System.tenant_id == tenant.id)
|
||||
)
|
||||
systems = list(systems_result.scalars().all())
|
||||
|
||||
if not systems:
|
||||
print("🖥️ Creando sistemas de prueba...")
|
||||
system_names = ["Portal Web", "API REST", "Base de Datos", "Sistema de Pagos"]
|
||||
for sys_name in system_names:
|
||||
system = System(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
name=sys_name,
|
||||
description=f"Sistema {sys_name}",
|
||||
is_active=True
|
||||
)
|
||||
db.add(system)
|
||||
systems.append(system)
|
||||
|
||||
await db.commit()
|
||||
print(f" ✓ Creados {len(systems)} sistemas")
|
||||
|
||||
print(f"✅ Usando tenant: {tenant.name}")
|
||||
print(f"✅ Usuarios disponibles: {len(users)}")
|
||||
print(f"✅ Categorías disponibles: {len(categories)}")
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
tickets_created = 0
|
||||
|
||||
# Función auxiliar para crear ticket
|
||||
def create_ticket(
|
||||
subject: str,
|
||||
description: str,
|
||||
priority: TicketPriority,
|
||||
status: TicketStatus,
|
||||
category: Category,
|
||||
created_hours_ago: int,
|
||||
first_response_hours_after: int = None,
|
||||
resolved_hours_after: int = None,
|
||||
assigned: bool = True
|
||||
):
|
||||
nonlocal tickets_created
|
||||
|
||||
ticket_id = uuid.uuid4()
|
||||
created_at = now - timedelta(hours=created_hours_ago)
|
||||
|
||||
# Calcular SLA deadlines basados en la categoría (sin timezone para la BD)
|
||||
sla_response_due = (created_at + timedelta(hours=category.sla_response_hours)).replace(tzinfo=None)
|
||||
sla_resolution_due = (created_at + timedelta(hours=category.sla_resolution_hours)).replace(tzinfo=None)
|
||||
|
||||
# Primera respuesta (si aplica)
|
||||
first_response_at = None
|
||||
if first_response_hours_after is not None:
|
||||
first_response_at = (created_at + timedelta(hours=first_response_hours_after)).replace(tzinfo=None)
|
||||
|
||||
# Resolución (si aplica)
|
||||
resolved_at = None
|
||||
if resolved_hours_after is not None:
|
||||
resolved_at = (created_at + timedelta(hours=resolved_hours_after)).replace(tzinfo=None)
|
||||
|
||||
ticket = Ticket(
|
||||
id=ticket_id,
|
||||
tenant_id=tenant.id,
|
||||
ticket_number=f"TKT-{1000 + tickets_created}",
|
||||
subject=subject,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
created_by=creator.id,
|
||||
assigned_to=random.choice(agents).id if assigned else None,
|
||||
category_id=category.id,
|
||||
affected_system_id=random.choice(systems).id if systems else None,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
first_response_at=first_response_at,
|
||||
resolved_at=resolved_at,
|
||||
created_at=created_at,
|
||||
updated_at=resolved_at or first_response_at or created_at
|
||||
)
|
||||
|
||||
db.add(ticket)
|
||||
tickets_created += 1
|
||||
return ticket
|
||||
|
||||
# ============================================
|
||||
# 1. TICKETS CUMPLIENDO SLA RESPONSE (Verde)
|
||||
# ============================================
|
||||
print("✅ Generando tickets CUMPLIENDO Response SLA...")
|
||||
|
||||
for i in range(15):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Creado hace X horas, respondido ANTES del deadline
|
||||
created_hours_ago = random.randint(24, 120)
|
||||
response_time = random.uniform(0.5, category.sla_response_hours * 0.7) # 70% del SLA
|
||||
|
||||
status = random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER])
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket con respuesta a tiempo #{i+1}",
|
||||
description=f"Este ticket fue respondido dentro del SLA de {category.name}",
|
||||
priority=priority,
|
||||
status=status,
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 15 tickets cumpliendo Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 2. TICKETS VIOLANDO SLA RESPONSE (Rojo)
|
||||
# ============================================
|
||||
print("🔴 Generando tickets VIOLANDO Response SLA...")
|
||||
|
||||
for i in range(8):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace más tiempo que el SLA, SIN respuesta
|
||||
created_hours_ago = category.sla_response_hours + random.randint(1, 10)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket SIN respuesta - VIOLACIÓN #{i+1}",
|
||||
description=f"Este ticket lleva {created_hours_ago}h sin respuesta (SLA: {category.sla_response_hours}h)",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.NEW, TicketStatus.TRIAGE]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=None, # Sin respuesta!
|
||||
assigned=random.choice([True, False])
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 8 tickets VIOLANDO Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 3. TICKETS EN RIESGO Response (Amarillo)
|
||||
# ============================================
|
||||
print("⚠️ Generando tickets EN RIESGO Response SLA...")
|
||||
|
||||
for i in range(10):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace tiempo, cerca del deadline (80-95% consumido)
|
||||
sla_hours = category.sla_response_hours
|
||||
time_consumed = random.uniform(0.8, 0.95) * sla_hours
|
||||
created_hours_ago = time_consumed
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket cerca de vencer respuesta #{i+1}",
|
||||
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de respuesta",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.TRIAGE, TicketStatus.NEW]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=None, # Aún sin respuesta
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 10 tickets EN RIESGO Response SLA")
|
||||
|
||||
# ============================================
|
||||
# 4. TICKETS CUMPLIENDO SLA RESOLUTION
|
||||
# ============================================
|
||||
print("✅ Generando tickets CUMPLIENDO Resolution SLA...")
|
||||
|
||||
for i in range(20):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Creado, respondido y resuelto dentro del SLA
|
||||
created_hours_ago = random.randint(72, 240)
|
||||
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||
resolution_time = random.uniform(
|
||||
response_time + 1,
|
||||
category.sla_resolution_hours * 0.8
|
||||
)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket resuelto a tiempo #{i+1}",
|
||||
description=f"Este ticket fue resuelto dentro del SLA de {category.name}",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=resolution_time,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 20 tickets cumpliendo Resolution SLA")
|
||||
|
||||
# ============================================
|
||||
# 5. TICKETS VIOLANDO SLA RESOLUTION
|
||||
# ============================================
|
||||
print("🔴 Generando tickets VIOLANDO Resolution SLA...")
|
||||
|
||||
for i in range(6):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.HIGH, TicketPriority.URGENT])
|
||||
|
||||
# Creado hace más del SLA de resolución, con respuesta pero sin resolver
|
||||
created_hours_ago = category.sla_resolution_hours + random.randint(5, 48)
|
||||
response_time = random.uniform(1, category.sla_response_hours * 0.5)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket sin resolver - VIOLACIÓN #{i+1}",
|
||||
description=f"Ticket lleva {created_hours_ago}h sin resolver (SLA: {category.sla_resolution_hours}h)",
|
||||
priority=priority,
|
||||
status=random.choice([TicketStatus.IN_PROGRESS, TicketStatus.WAITING_CUSTOMER]),
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=None, # Sin resolver!
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 6 tickets VIOLANDO Resolution SLA")
|
||||
|
||||
# ============================================
|
||||
# 6. TICKETS EN RIESGO Resolution
|
||||
# ============================================
|
||||
print("⚠️ Generando tickets EN RIESGO Resolution SLA...")
|
||||
|
||||
for i in range(12):
|
||||
category = random.choice(categories)
|
||||
priority = random.choice([TicketPriority.MEDIUM, TicketPriority.HIGH])
|
||||
|
||||
# Con respuesta, cerca del deadline de resolución
|
||||
sla_hours = category.sla_resolution_hours
|
||||
time_consumed = random.uniform(0.75, 0.95) * sla_hours
|
||||
created_hours_ago = time_consumed
|
||||
response_time = random.uniform(0.5, category.sla_response_hours * 0.5)
|
||||
|
||||
create_ticket(
|
||||
subject=f"Ticket cerca de vencer resolución #{i+1}",
|
||||
description=f"Este ticket está al {int(time_consumed/sla_hours*100)}% del SLA de resolución",
|
||||
priority=priority,
|
||||
status=TicketStatus.IN_PROGRESS,
|
||||
category=category,
|
||||
created_hours_ago=created_hours_ago,
|
||||
first_response_hours_after=response_time,
|
||||
resolved_hours_after=None,
|
||||
assigned=True
|
||||
)
|
||||
|
||||
print(f" ✓ Creados 12 tickets EN RIESGO Resolution SLA")
|
||||
|
||||
# Guardar todos los tickets
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("✅ GENERACIÓN DE DATOS SLA COMPLETADA")
|
||||
print(f" Total de tickets creados: {tickets_created}")
|
||||
print()
|
||||
print("📊 Distribución esperada:")
|
||||
print(" ✅ Response cumplidos: 15 tickets")
|
||||
print(" 🔴 Response violados: 8 tickets")
|
||||
print(" ⚠️ Response en riesgo: 10 tickets")
|
||||
print(" ✅ Resolution cumplidos: 20 tickets")
|
||||
print(" 🔴 Resolution violados: 6 tickets")
|
||||
print(" ⚠️ Resolution en riesgo: 12 tickets")
|
||||
print()
|
||||
print("🌐 Ve los resultados en:")
|
||||
print(" Dashboard SLA: http://localhost:3001/sla")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
async def cleanup_sla_test_data():
|
||||
"""Elimina tickets de prueba."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
print("❌ No se encontró ningún tenant.")
|
||||
return
|
||||
|
||||
# Eliminar tickets que empiezan con TKT-
|
||||
result = await db.execute(
|
||||
select(Ticket).where(
|
||||
Ticket.tenant_id == tenant.id,
|
||||
Ticket.ticket_number.like('TKT-%')
|
||||
)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
if not tickets:
|
||||
print("ℹ️ No hay tickets de prueba para eliminar.")
|
||||
return
|
||||
|
||||
for ticket in tickets:
|
||||
await db.delete(ticket)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"✅ Eliminados {len(tickets)} tickets de prueba")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "cleanup":
|
||||
print("🧹 Limpiando datos de prueba de SLA...")
|
||||
print()
|
||||
asyncio.run(cleanup_sla_test_data())
|
||||
else:
|
||||
print("🚀 Generando datos de prueba para SLA Management...")
|
||||
print()
|
||||
asyncio.run(generate_sla_test_data())
|
||||
print()
|
||||
print("💡 Para limpiar estos datos de prueba, ejecuta:")
|
||||
print(" python scripts/generate_sla_test_data.py cleanup")
|
||||
@@ -50,4 +50,4 @@
|
||||
on:dismiss={() => toast.dismiss(toastMessage.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,28 +16,28 @@
|
||||
description: 'Gestión de organizaciones y tenants',
|
||||
icon: 'users',
|
||||
href: '/tenants',
|
||||
color: 'bg-blue-500'
|
||||
color: 'bg-blue-600'
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
description: 'Administración de usuarios y roles',
|
||||
icon: 'user-plus',
|
||||
href: '/users',
|
||||
color: 'bg-green-500'
|
||||
color: 'bg-green-600'
|
||||
},
|
||||
{
|
||||
title: 'Sistemas',
|
||||
description: 'Catálogo de sistemas soportados',
|
||||
icon: 'server',
|
||||
href: '/systems',
|
||||
color: 'bg-purple-500'
|
||||
color: 'bg-gray-700'
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
description: 'Clasificación de tickets',
|
||||
icon: 'tag',
|
||||
href: '/categories',
|
||||
color: 'bg-orange-500'
|
||||
color: 'bg-orange-600'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
@@ -62,32 +62,20 @@
|
||||
{#each cards as card}
|
||||
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="{card.color} rounded-md p-3">
|
||||
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
{card.title}
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-xs text-gray-900 font-light mt-1">
|
||||
{card.description}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||
{card.title}
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-xs text-gray-900 font-light mt-1">
|
||||
{card.description}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-cyan-700 hover:text-cyan-900">
|
||||
<span class="font-medium text-blue-700 hover:text-blue-900">
|
||||
Ver detalles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1350
frontend-internal/src/routes/audit/+page.svelte.tmp
Normal file
1350
frontend-internal/src/routes/audit/+page.svelte.tmp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,19 +7,61 @@
|
||||
|
||||
// Estado
|
||||
let isLoading = false;
|
||||
let analysis = null;
|
||||
let analysis: any = null;
|
||||
let analysisHours = 24;
|
||||
let selectedThreat = null;
|
||||
let selectedThreat: any = null;
|
||||
let showActionModal = false;
|
||||
let actionType = '';
|
||||
let actionTarget = '';
|
||||
let actionReason = '';
|
||||
let actionDuration = 60;
|
||||
|
||||
// Filtros y búsqueda
|
||||
let activeTab: 'all' | 'critical' | 'high' | 'medium' | 'low' | 'resolved' = 'all';
|
||||
let searchQuery = '';
|
||||
let filterType = '';
|
||||
let showFilters = false;
|
||||
let selectedThreats = new Set<string>();
|
||||
|
||||
// Estado de amenazas resueltas (simulado - idealmente vendría del backend)
|
||||
let resolvedThreats = new Set<string>();
|
||||
|
||||
// Usuario actual
|
||||
$: currentUser = $auth.user;
|
||||
$: canExecuteActions = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER');
|
||||
|
||||
// Amenazas filtradas
|
||||
$: filteredThreats = analysis?.threats?.filter((threat: any) => {
|
||||
const matchesTab =
|
||||
activeTab === 'all' ? !resolvedThreats.has(threat.id) :
|
||||
activeTab === 'resolved' ? resolvedThreats.has(threat.id) :
|
||||
(threat.severity === activeTab && !resolvedThreats.has(threat.id));
|
||||
|
||||
const matchesSearch = !searchQuery ||
|
||||
threat.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
threat.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
threat.affected_ips.some((ip: string) => ip.includes(searchQuery)) ||
|
||||
threat.affected_users.some((user: string) => user.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const matchesType = !filterType || threat.type === filterType;
|
||||
|
||||
return matchesTab && matchesSearch && matchesType;
|
||||
}) || [];
|
||||
|
||||
// Contadores por tab
|
||||
$: tabCounts = {
|
||||
all: analysis?.threats?.filter((t: any) => !resolvedThreats.has(t.id)).length || 0,
|
||||
critical: analysis?.threats?.filter((t: any) => t.severity === 'critical' && !resolvedThreats.has(t.id)).length || 0,
|
||||
high: analysis?.threats?.filter((t: any) => t.severity === 'high' && !resolvedThreats.has(t.id)).length || 0,
|
||||
medium: analysis?.threats?.filter((t: any) => t.severity === 'medium' && !resolvedThreats.has(t.id)).length || 0,
|
||||
low: analysis?.threats?.filter((t: any) => t.severity === 'low' && !resolvedThreats.has(t.id)).length || 0,
|
||||
resolved: resolvedThreats.size
|
||||
};
|
||||
|
||||
// Tipos únicos de amenazas
|
||||
$: threatTypes = analysis?.threats ?
|
||||
[...new Set(analysis.threats.map((t: any) => t.type))] : [];
|
||||
|
||||
/**
|
||||
* Cargar análisis de seguridad
|
||||
*/
|
||||
@@ -44,32 +86,77 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener color según nivel de riesgo
|
||||
* Obtener color según nivel de riesgo (neutral)
|
||||
*/
|
||||
function getRiskColor(level: string) {
|
||||
const colors: any = {
|
||||
safe: 'bg-green-100 text-green-800 border-green-300',
|
||||
low: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
high: 'bg-orange-100 text-orange-800 border-orange-300',
|
||||
critical: 'bg-red-100 text-red-800 border-red-300'
|
||||
safe: 'bg-gray-50 text-green-700 border border-green-200',
|
||||
low: 'bg-gray-50 text-blue-700 border border-blue-200',
|
||||
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
|
||||
high: 'bg-gray-50 text-orange-700 border border-orange-200',
|
||||
critical: 'bg-gray-50 text-red-700 border border-red-200'
|
||||
};
|
||||
return colors[level] || colors.low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener color de severidad de amenaza
|
||||
* Obtener color de severidad de amenaza (neutral)
|
||||
*/
|
||||
function getSeverityColor(severity: string) {
|
||||
const colors: any = {
|
||||
low: 'bg-blue-600 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
high: 'bg-orange-600 text-white',
|
||||
critical: 'bg-red-600 text-white'
|
||||
low: 'bg-gray-50 text-blue-700 border border-blue-200',
|
||||
medium: 'bg-gray-50 text-yellow-800 border border-yellow-200',
|
||||
high: 'bg-gray-50 text-orange-700 border border-orange-200',
|
||||
critical: 'bg-gray-50 text-red-700 border border-red-200'
|
||||
};
|
||||
return colors[severity] || colors.low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar amenaza como resuelta
|
||||
*/
|
||||
function toggleThreatResolved(threatId: string) {
|
||||
if (resolvedThreats.has(threatId)) {
|
||||
resolvedThreats.delete(threatId);
|
||||
} else {
|
||||
resolvedThreats.add(threatId);
|
||||
}
|
||||
resolvedThreats = resolvedThreats; // Trigger reactivity
|
||||
toast.success(resolvedThreats.has(threatId) ? 'Amenaza marcada como resuelta' : 'Amenaza marcada como activa');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleccionar/deseleccionar amenaza
|
||||
*/
|
||||
function toggleThreatSelection(threatId: string) {
|
||||
if (selectedThreats.has(threatId)) {
|
||||
selectedThreats.delete(threatId);
|
||||
} else {
|
||||
selectedThreats.add(threatId);
|
||||
}
|
||||
selectedThreats = selectedThreats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver amenazas en lote
|
||||
*/
|
||||
function resolveSelectedThreats() {
|
||||
selectedThreats.forEach(id => resolvedThreats.add(id));
|
||||
resolvedThreats = resolvedThreats;
|
||||
selectedThreats.clear();
|
||||
selectedThreats = selectedThreats;
|
||||
toast.success(`${resolvedThreats.size} amenazas resueltas`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar filtros
|
||||
*/
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
filterType = '';
|
||||
activeTab = 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener icono de tipo de amenaza
|
||||
*/
|
||||
@@ -174,130 +261,140 @@
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<!-- Header con Breadcrumb -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex mb-3" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2">
|
||||
<li>
|
||||
<a href="/audit" class="text-gray-500 hover:text-gray-700 text-sm">Auditoría</a>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="text-gray-400 mx-2">/</span>
|
||||
<span class="text-sm font-medium text-gray-900">Análisis de Seguridad</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Análisis de Seguridad
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Detección de amenazas y análisis de vulnerabilidades
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Análisis de Seguridad</h1>
|
||||
<p class="text-sm text-gray-500">Detección de amenazas y gestión de incidentes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
on:click={loadSecurityAnalysis}
|
||||
class="px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => loadSecurityAnalysis()}
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selector de Período -->
|
||||
<div class="bg-white shadow rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Período de Análisis</span>
|
||||
<div class="bg-white shadow-sm rounded-lg p-4 mb-6 border border-gray-200">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-sm font-semibold text-gray-700">Período de Análisis</span>
|
||||
</div>
|
||||
{#if analysis}
|
||||
<span class="text-xs text-gray-500">
|
||||
Última actualización: {formatDate(analysis.generated_at)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(24)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Últimas 24 horas
|
||||
Últimas 24h
|
||||
</button>
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(48)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Últimas 48 horas
|
||||
Últimas 48h
|
||||
</button>
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(168)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Última semana
|
||||
</button>
|
||||
<button
|
||||
on:click={() => changeAnalysisPeriod(720)}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 720 ? 'bg-blue-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-blue-50'}"
|
||||
>
|
||||
Último mes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700 mx-auto mb-4"></div>
|
||||
<p class="text-sm text-gray-500">Analizando seguridad...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if analysis}
|
||||
<!-- Resumen de Riesgo -->
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6 border-l-4 {getRiskColor(analysis.overall_risk_level)}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Nivel de Riesgo General</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Análisis de {analysis.analysis_period_hours} horas</p>
|
||||
<!-- Dashboard KPIs -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<!-- Nivel de Riesgo -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-200 lg:col-span-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold text-gray-600 uppercase">Nivel de Riesgo</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="inline-block px-4 py-2 text-2xl font-bold rounded-lg {getRiskColor(analysis.overall_risk_level)}">
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-bold {getRiskColor(analysis.overall_risk_level)}">
|
||||
{analysis.overall_risk_level.toUpperCase()}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 mt-1">Generado: {formatDate(analysis.generated_at)}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{analysis.analysis_period_hours}h análisis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estadísticas Rápidas -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- Amenazas Detectadas -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-red-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
|
||||
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Amenazas</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.total_threats_detected}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Detectadas</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- Intentos Fallidos -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-orange-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Intentos Fallidos</p>
|
||||
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Intentos Fallidos</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.failed_login_attempts}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Logins rechazados</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- IPs Sospechosas -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-yellow-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">IPs Sospechosas</p>
|
||||
<p class="text-2xl font-bold text-yellow-600">{analysis.suspicious_ips_count}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">IPs Sospechosas</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.suspicious_ips_count}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">En seguimiento</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<!-- Acciones Críticas -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 border-l-4 border-blue-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Acciones Críticas</p>
|
||||
<p class="text-2xl font-bold text-red-600">{analysis.critical_actions_count}</p>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-600 uppercase mb-1">Acciones Críticas</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{analysis.critical_actions_count}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Registradas</p>
|
||||
</div>
|
||||
<svg class="w-10 h-10 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,162 +402,335 @@
|
||||
<!-- Recomendaciones Generales -->
|
||||
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each analysis.recommended_actions as action}
|
||||
<li class="text-sm text-blue-800 flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{action}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each analysis.recommended_actions as action}
|
||||
<li class="text-sm text-blue-800">{action}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs y Filtros -->
|
||||
<div class="bg-white shadow-sm rounded-lg mb-6 border border-gray-200 overflow-hidden">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 bg-gray-50">
|
||||
<div class="flex overflow-x-auto">
|
||||
<button
|
||||
on:click={() => activeTab = 'all'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'all' ? 'border-blue-700 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Todas
|
||||
{#if tabCounts.all > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'all' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.all}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'critical'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'critical' ? 'border-red-600 text-red-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Críticas
|
||||
{#if tabCounts.critical > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'critical' ? 'bg-red-100 text-red-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.critical}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'high'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'high' ? 'border-orange-500 text-orange-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Altas
|
||||
{#if tabCounts.high > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.high}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'medium'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'medium' ? 'border-yellow-500 text-yellow-800 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Medias
|
||||
{#if tabCounts.medium > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'medium' ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.medium}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'low'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'low' ? 'border-blue-500 text-blue-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Bajas
|
||||
{#if tabCounts.low > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'low' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.low}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'resolved'}
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === 'resolved' ? 'border-green-500 text-green-700 bg-white' : 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'}"
|
||||
>
|
||||
Resueltas
|
||||
{#if tabCounts.resolved > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {activeTab === 'resolved' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-700'}">
|
||||
{tabCounts.resolved}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Búsqueda y Filtros -->
|
||||
<div class="p-4 bg-white">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<!-- Búsqueda -->
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Buscar por descripción, IP, usuario..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Tipo -->
|
||||
<select
|
||||
bind:value={filterType}
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{#each threatTypes as type}
|
||||
<option value={type}>{getThreatTypeText(type)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Limpiar -->
|
||||
{#if searchQuery || filterType}
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Acciones en Lote -->
|
||||
{#if selectedThreats.size > 0 && canExecuteActions}
|
||||
<div class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-blue-900">
|
||||
{selectedThreats.size} amenaza{selectedThreats.size > 1 ? 's' : ''} seleccionada{selectedThreats.size > 1 ? 's' : ''}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
on:click={resolveSelectedThreats}
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 font-medium"
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
<button
|
||||
on:click={() => { selectedThreats.clear(); selectedThreats = selectedThreats; }}
|
||||
class="px-3 py-1.5 bg-white border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Amenazas -->
|
||||
{#if analysis.threats && analysis.threats.length > 0}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
|
||||
|
||||
{#each analysis.threats as threat}
|
||||
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
|
||||
<!-- Header de Amenaza -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
|
||||
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h4 class="text-lg font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {getSeverityColor(threat.severity)}">
|
||||
{threat.severity.toUpperCase()}
|
||||
</span>
|
||||
{#if filteredThreats.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each filteredThreats as threat}
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden {resolvedThreats.has(threat.id) ? 'opacity-60' : ''}">
|
||||
<!-- Header Compacto -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<!-- Checkbox de Selección -->
|
||||
{#if canExecuteActions && !resolvedThreats.has(threat.id)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedThreats.has(threat.id)}
|
||||
on:change={() => toggleThreatSelection(threat.id)}
|
||||
class="mt-1 h-4 w-4 text-blue-700 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Icono -->
|
||||
|
||||
<!-- Información Principal -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h4 class="text-base font-semibold text-gray-900">{getThreatTypeText(threat.type)}</h4>
|
||||
<span class="px-2 py-0.5 text-xs font-semibold rounded {getSeverityColor(threat.severity)}">
|
||||
{threat.severity.toUpperCase()}
|
||||
</span>
|
||||
{#if resolvedThreats.has(threat.id)}
|
||||
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-gray-50 text-green-700 border border-green-200">
|
||||
RESUELTA
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 mb-2">{threat.description}</p>
|
||||
|
||||
<!-- Stats Rápidos -->
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-600">
|
||||
<span><strong>{threat.occurrences}</strong> ocurrencias</span>
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<span><strong>{threat.affected_ips.length}</strong> IPs</span>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<span><strong>{threat.affected_users.length}</strong> usuarios</span>
|
||||
{/if}
|
||||
<span class="text-gray-500">|</span>
|
||||
<span>{formatDate(threat.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
<!-- IPs y Usuarios (Collapsibles) -->
|
||||
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
|
||||
<details class="mt-3 group">
|
||||
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
|
||||
Ver detalles afectados
|
||||
</summary>
|
||||
<div class="mt-2 pl-5 space-y-2">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-600">IPs:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_ips as ip}
|
||||
<code class="px-2 py-0.5 bg-gray-100 rounded text-xs font-mono">{ip}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-600">Usuarios:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_users as user}
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">{user}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<!-- Recomendaciones (Collapsibles) -->
|
||||
{#if threat.recommendations && threat.recommendations.length > 0}
|
||||
<details class="mt-2 group">
|
||||
<summary class="cursor-pointer text-xs font-medium text-blue-700 hover:text-blue-800">
|
||||
Ver recomendaciones
|
||||
</summary>
|
||||
<div class="mt-2 pl-5">
|
||||
<ul class="space-y-1">
|
||||
{#each threat.recommendations as rec}
|
||||
<li class="text-xs text-gray-600">
|
||||
{rec}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">{threat.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Acciones Rápidas -->
|
||||
{#if canExecuteActions}
|
||||
<div class="flex flex-col gap-2 flex-shrink-0">
|
||||
{#if !resolvedThreats.has(threat.id)}
|
||||
<button
|
||||
on:click={() => toggleThreatResolved(threat.id)}
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 font-medium whitespace-nowrap"
|
||||
title="Marcar como resuelta"
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => toggleThreatResolved(threat.id)}
|
||||
class="px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded-lg hover:bg-gray-300 font-medium whitespace-nowrap"
|
||||
title="Marcar como activa"
|
||||
>
|
||||
Reoprir
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !resolvedThreats.has(threat.id)}
|
||||
<div class="relative group/actions">
|
||||
<button class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded-lg hover:bg-gray-200 font-medium whitespace-nowrap">
|
||||
Acciones
|
||||
</button>
|
||||
<div class="hidden group-hover/actions:block absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'block_ip')}
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Bloquear IP
|
||||
</button>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'force_password_reset')}
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Resetear Contraseña
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'notify_admin')}
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Notificar Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalles -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Ocurrencias:</span>
|
||||
<span class="ml-2 text-gray-900 font-semibold">{threat.occurrences}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Primera detección:</span>
|
||||
<span class="ml-2 text-gray-900">{formatDate(threat.first_seen)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Última detección:</span>
|
||||
<span class="ml-2 text-gray-900">{formatDate(threat.last_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPs y Usuarios Afectados -->
|
||||
{#if threat.affected_ips.length > 0 || threat.affected_users.length > 0}
|
||||
<div class="mb-4 text-sm">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<div class="mb-2">
|
||||
<span class="font-medium text-gray-600">IPs involucradas:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_ips as ip}
|
||||
<code class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{ip}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">Usuarios afectados:</span>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each threat.affected_users as user}
|
||||
<span class="px-2 py-1 bg-gray-100 rounded text-xs">{user}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recomendaciones -->
|
||||
{#if threat.recommendations && threat.recommendations.length > 0}
|
||||
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
||||
<p class="text-xs font-semibold text-gray-700 mb-2">Recomendaciones:</p>
|
||||
<ul class="space-y-1">
|
||||
{#each threat.recommendations as rec}
|
||||
<li class="text-xs text-gray-600 flex items-start gap-2">
|
||||
<svg class="w-3 h-3 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{rec}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Acciones -->
|
||||
{#if canExecuteActions}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if threat.affected_ips.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'block_ip')}
|
||||
class="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
Bloquear IP
|
||||
</button>
|
||||
{/if}
|
||||
{#if threat.affected_users.length > 0}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'force_password_reset')}
|
||||
class="px-3 py-1.5 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Resetear Contraseña
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => openActionModal(threat, 'notify_admin')}
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
Notificar Admin
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No hay amenazas -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
|
||||
<p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
|
||||
<!-- No hay amenazas filtradas -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
{activeTab === 'resolved' ? 'No hay amenazas resueltas' : searchQuery || filterType ? 'No se encontraron resultados' : 'Sistema Seguro'}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{activeTab === 'resolved' ? 'No has resuelto ninguna amenaza aún.' : searchQuery || filterType ? 'Intenta ajustar los filtros de búsqueda.' : 'No se detectaron amenazas en este período.'}
|
||||
</p>
|
||||
{#if searchQuery || filterType}
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="mt-4 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
|
||||
>
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Estado vacío inicial -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-12 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sin Datos de Análisis</h3>
|
||||
<p class="text-sm text-gray-600">Carga el análisis de seguridad para ver amenazas detectadas.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -522,7 +792,7 @@
|
||||
</button>
|
||||
<button
|
||||
on:click={executeSecurityAction}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Ejecutar Acción
|
||||
</button>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
|
||||
>
|
||||
Nueva Categoría
|
||||
</button>
|
||||
@@ -145,27 +145,27 @@
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
|
||||
<td class="px-3 py-4 text-sm text-center">
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
⏱️ {category.sla_response_hours || 24}h
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 border border-blue-200">
|
||||
{category.sla_response_hours || 24}h
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-center">
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
✅ {category.sla_resolution_hours || 72}h
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-green-700 border border-green-200">
|
||||
{category.sla_resolution_hours || 72}h
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {!category.tenant_id ? 'bg-gray-100 text-blue-700 border-blue-200' : 'bg-gray-100 text-gray-700 border-gray-200'}">
|
||||
{getTenantName(category.tenant_id)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {category.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
|
||||
{category.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button on:click={() => openEditModal(category)} class="text-blue-700 hover:text-blue-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -182,18 +182,18 @@
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre *</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="color" class="block text-sm font-medium text-gray-700">Color</label>
|
||||
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border">
|
||||
<input type="color" id="color" bind:value={formData.color} class="mt-1 block w-full h-10 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
min="1"
|
||||
max="168"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para primera respuesta</p>
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@
|
||||
min="1"
|
||||
max="720"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-500">Tiempo máximo para resolver el ticket</p>
|
||||
</div>
|
||||
@@ -247,7 +247,7 @@
|
||||
|
||||
<div>
|
||||
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
<option value="">-- Global (Para todos) --</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
@@ -256,15 +256,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
<div class="max-w-sm mx-auto w-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Identifíquese</h2>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||
</div>
|
||||
|
||||
@@ -208,4 +208,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
{$auth.user?.first_name} {$auth.user?.last_name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">{$auth.user?.email}</p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-gray-100 text-blue-700 rounded-full border border-blue-200">
|
||||
{roleLabel($auth.user?.role)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@
|
||||
<div>
|
||||
<dt class="text-gray-500 font-medium">Estado</dt>
|
||||
<dd class="mt-0.5">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {$auth.user?.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border {$auth.user?.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
|
||||
{$auth.user?.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</dd>
|
||||
@@ -219,10 +219,8 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center border border-green-200">
|
||||
<span class="text-xs font-bold text-green-600">2FA</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">2FA habilitado</p>
|
||||
@@ -230,9 +228,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold text-gray-400">2FA</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">2FA no habilitado</p>
|
||||
@@ -306,7 +302,7 @@
|
||||
<!-- Backup codes -->
|
||||
{#if showBackupCodes && backupCodes.length > 0}
|
||||
<div class="mt-5 pt-5 border-t border-green-200 bg-green-50 rounded-b-lg -mx-6 -mb-5 px-6 pb-5">
|
||||
<h4 class="font-semibold text-green-900 mb-1">✅ 2FA activado — Guarda tus códigos de respaldo</h4>
|
||||
<h4 class="font-semibold text-green-900 mb-1">2FA activado — Guarda tus códigos de respaldo</h4>
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
Estos códigos son de <strong>un solo uso</strong>. Guárdalos en un lugar seguro para acceder sin tu dispositivo.
|
||||
</p>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<select
|
||||
bind:value={selectedPeriod}
|
||||
on:change={loadDashboard}
|
||||
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value={7}>Últimos 7 días</option>
|
||||
<option value={30}>Últimos 30 días</option>
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
{#if isLoading}
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-700"></div>
|
||||
<p class="mt-2 text-sm text-gray-500">Cargando métricas...</p>
|
||||
</div>
|
||||
{:else if dashboardData}
|
||||
@@ -75,106 +75,70 @@
|
||||
<!-- Response SLA -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
|
||||
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
|
||||
</div>
|
||||
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
|
||||
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
|
||||
</div>
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Response SLA</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold {getRiskColor(dashboardData.response_sla.compliance_percentage)}">
|
||||
{dashboardData.response_sla.compliance_percentage.toFixed(1)}%
|
||||
</div>
|
||||
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.response_sla)}">
|
||||
{getTrendIcon(dashboardData.trends.response_sla)} {dashboardData.trends.response_sla}
|
||||
</div>
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
{dashboardData.response_sla.met_count} / {dashboardData.response_sla.total_count} cumplidos
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution SLA -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
|
||||
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
|
||||
</div>
|
||||
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
|
||||
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
|
||||
</div>
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Resolution SLA</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold {getRiskColor(dashboardData.resolution_sla.compliance_percentage)}">
|
||||
{dashboardData.resolution_sla.compliance_percentage.toFixed(1)}%
|
||||
</div>
|
||||
<div class="ml-2 flex items-baseline text-sm font-semibold {getTrendColor(dashboardData.trends.resolution_sla)}">
|
||||
{getTrendIcon(dashboardData.trends.resolution_sla)} {dashboardData.trends.resolution_sla}
|
||||
</div>
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
{dashboardData.resolution_sla.met_count} / {dashboardData.resolution_sla.total_count} cumplidos
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Violations -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
|
||||
<dd class="text-2xl font-semibold text-red-600">
|
||||
{dashboardData.active_violations}
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
<a href="/sla/violations" class="text-indigo-600 hover:text-indigo-900">Ver detalles →</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Violaciones Activas</dt>
|
||||
<dd class="text-2xl font-semibold text-red-600">
|
||||
{dashboardData.active_violations}
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
<a href="/sla/violations" class="text-blue-700 hover:text-blue-900">Ver detalles →</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- At Risk Tickets -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
|
||||
<dd class="text-2xl font-semibold text-yellow-600">
|
||||
{dashboardData.at_risk_tickets}
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
<a href="/sla/at-risk" class="text-indigo-600 hover:text-indigo-900">Ver lista →</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Tickets en Riesgo</dt>
|
||||
<dd class="text-2xl font-semibold text-yellow-600">
|
||||
{dashboardData.at_risk_tickets}
|
||||
</dd>
|
||||
<dd class="mt-1 text-xs text-gray-500">
|
||||
<a href="/sla/at-risk" class="text-blue-700 hover:text-blue-900">Ver lista →</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,28 +221,18 @@
|
||||
href="/sla/violations"
|
||||
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<svg class="mr-3 h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Ver Violaciones
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<svg class="mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configurar SLAs
|
||||
</a>
|
||||
<a
|
||||
href="/tickets"
|
||||
class="flex items-center justify-center px-4 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<svg class="mr-3 h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
Ver Todos los Tickets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,62 @@
|
||||
let isLoading = true;
|
||||
let atRiskTickets: any[] = [];
|
||||
let threshold = 80;
|
||||
let page = 1;
|
||||
let perPage = 10;
|
||||
let totalTickets = 0;
|
||||
|
||||
// Filtros
|
||||
let filterPriority = 'ALL';
|
||||
let filterRiskLevel = 'ALL';
|
||||
let filterSLAType = 'ALL';
|
||||
let searchQuery = '';
|
||||
|
||||
// Tickets filtrados
|
||||
$: filteredTickets = atRiskTickets.filter(ticket => {
|
||||
// Filtro por prioridad
|
||||
if (filterPriority !== 'ALL' && ticket.ticket.priority !== filterPriority) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtro por nivel de riesgo
|
||||
if (filterRiskLevel !== 'ALL') {
|
||||
if (filterRiskLevel === 'CRITICAL' && ticket.risk_percentage < 95) return false;
|
||||
if (filterRiskLevel === 'HIGH' && (ticket.risk_percentage < 90 || ticket.risk_percentage >= 95)) return false;
|
||||
if (filterRiskLevel === 'MEDIUM' && (ticket.risk_percentage < 80 || ticket.risk_percentage >= 90)) return false;
|
||||
}
|
||||
|
||||
// Filtro por tipo de SLA
|
||||
if (filterSLAType !== 'ALL' && ticket.sla_type !== filterSLAType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Búsqueda por número de ticket o asunto
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesNumber = ticket.ticket.ticket_number?.toLowerCase().includes(query);
|
||||
const matchesSubject = ticket.ticket.subject?.toLowerCase().includes(query);
|
||||
const matchesCategory = ticket.category?.name?.toLowerCase().includes(query);
|
||||
return matchesNumber || matchesSubject || matchesCategory;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$: totalTickets = filteredTickets.length;
|
||||
$: paginatedTickets = filteredTickets.slice((page - 1) * perPage, page * perPage);
|
||||
$: totalPages = Math.ceil(totalTickets / perPage);
|
||||
|
||||
// Reset página cuando cambian filtros
|
||||
$: if (filterPriority || filterRiskLevel || filterSLAType || searchQuery) {
|
||||
page = 1;
|
||||
}
|
||||
|
||||
async function loadAtRiskTickets() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const data: any = await api.get(`/sla/at-risk?threshold=${threshold}`);
|
||||
atRiskTickets = data.tickets || [];
|
||||
page = 1; // Reset a primera página al cambiar filtros
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error cargando tickets en riesgo');
|
||||
atRiskTickets = [];
|
||||
@@ -20,6 +70,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterPriority = 'ALL';
|
||||
filterRiskLevel = 'ALL';
|
||||
filterSLAType = 'ALL';
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function getActiveFiltersCount(): number {
|
||||
let count = 0;
|
||||
if (filterPriority !== 'ALL') count++;
|
||||
if (filterRiskLevel !== 'ALL') count++;
|
||||
if (filterSLAType !== 'ALL') count++;
|
||||
if (searchQuery) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
$: activeFiltersCount = getActiveFiltersCount();
|
||||
|
||||
function formatHours(hours: number): string {
|
||||
if (hours < 1) {
|
||||
return `${Math.round(hours * 60)} min`;
|
||||
@@ -33,10 +101,17 @@
|
||||
}
|
||||
|
||||
function getRiskColor(percentage: number): string {
|
||||
if (percentage >= 95) return 'bg-red-100 text-red-800 border-red-200';
|
||||
if (percentage >= 90) return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
if (percentage >= 95) return 'border-red-500';
|
||||
if (percentage >= 90) return 'border-orange-500';
|
||||
if (percentage >= 80) return 'border-yellow-500';
|
||||
return 'border-blue-500';
|
||||
}
|
||||
|
||||
function getRiskBadgeColor(percentage: number): string {
|
||||
if (percentage >= 95) return 'bg-red-100 text-red-800';
|
||||
if (percentage >= 90) return 'bg-orange-100 text-orange-800';
|
||||
if (percentage >= 80) return 'bg-yellow-100 text-yellow-800';
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
|
||||
function getRiskLabel(percentage: number): string {
|
||||
@@ -60,6 +135,20 @@
|
||||
return type === 'response' ? 'Respuesta' : 'Resolución';
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page < totalPages) {
|
||||
page++;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page > 1) {
|
||||
page--;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadAtRiskTickets);
|
||||
</script>
|
||||
|
||||
@@ -76,7 +165,7 @@
|
||||
<select
|
||||
bind:value={threshold}
|
||||
on:change={loadAtRiskTickets}
|
||||
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
class="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value={70}>70% del tiempo</option>
|
||||
<option value={80}>80% del tiempo</option>
|
||||
@@ -91,115 +180,209 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="mt-6 bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<!-- Filtros -->
|
||||
<div class="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Filtros</h3>
|
||||
{#if activeFiltersCount > 0}
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Limpiar ({activeFiltersCount})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- Búsqueda -->
|
||||
<div>
|
||||
<label for="search" class="block text-xs font-medium text-gray-700 mb-1">Buscar</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Ticket, asunto, categoría..."
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
Mostrando tickets que han consumido {threshold}% o más de su tiempo SLA.
|
||||
Estos tickets requieren atención prioritaria para evitar violaciones.
|
||||
</p>
|
||||
|
||||
<!-- Filtro por Prioridad -->
|
||||
<div>
|
||||
<label for="priority" class="block text-xs font-medium text-gray-700 mb-1">Prioridad</label>
|
||||
<select
|
||||
id="priority"
|
||||
bind:value={filterPriority}
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="ALL">Todas</option>
|
||||
<option value="URGENT">Urgente</option>
|
||||
<option value="HIGH">Alta</option>
|
||||
<option value="MEDIUM">Media</option>
|
||||
<option value="LOW">Baja</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Nivel de Riesgo -->
|
||||
<div>
|
||||
<label for="riskLevel" class="block text-xs font-medium text-gray-700 mb-1">Nivel de Riesgo</label>
|
||||
<select
|
||||
id="riskLevel"
|
||||
bind:value={filterRiskLevel}
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="ALL">Todos</option>
|
||||
<option value="CRITICAL">Crítico (≥95%)</option>
|
||||
<option value="HIGH">Alto (90-95%)</option>
|
||||
<option value="MEDIUM">Medio (80-90%)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filtro por Tipo de SLA -->
|
||||
<div>
|
||||
<label for="slaType" class="block text-xs font-medium text-gray-700 mb-1">Tipo de SLA</label>
|
||||
<select
|
||||
id="slaType"
|
||||
bind:value={filterSLAType}
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="ALL">Todos</option>
|
||||
<option value="response">Respuesta</option>
|
||||
<option value="resolution">Resolución</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contador de resultados -->
|
||||
<div class="mt-3 pt-3 border-t border-gray-200">
|
||||
<p class="text-xs text-gray-600">
|
||||
Mostrando <span class="font-semibold text-gray-900">{totalTickets}</span>
|
||||
{totalTickets === 1 ? 'ticket' : 'tickets'}
|
||||
{#if activeFiltersCount > 0}
|
||||
de <span class="font-semibold">{atRiskTickets.length}</span> totales
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if !isLoading && filteredTickets.length > 0}
|
||||
<div class="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Resumen por Nivel de Riesgo</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="bg-gray-50 rounded-lg p-3 border-l-4 border-gray-400">
|
||||
<p class="text-xs font-medium text-gray-600">Total en Riesgo</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">{totalTickets}</p>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-3 border-l-4 border-red-500">
|
||||
<p class="text-xs font-medium text-gray-600">Crítico (≥95%)</p>
|
||||
<p class="mt-1 text-2xl font-bold text-red-600">
|
||||
{filteredTickets.filter(t => t.risk_percentage >= 95).length}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-lg p-3 border-l-4 border-orange-500">
|
||||
<p class="text-xs font-medium text-gray-600">Alto (90-95%)</p>
|
||||
<p class="mt-1 text-2xl font-bold text-orange-600">
|
||||
{filteredTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg p-3 border-l-4 border-yellow-500">
|
||||
<p class="text-xs font-medium text-gray-600">Medio (80-90%)</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600">
|
||||
{filteredTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="mt-4 bg-amber-50 border-l-4 border-amber-500 p-3 rounded-lg">
|
||||
<p class="text-xs text-amber-800">
|
||||
<strong class="font-bold">{totalTickets}</strong> {totalTickets === 1 ? 'ticket' : 'tickets'} consumiendo <strong>{threshold}%</strong> o más del tiempo SLA.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Risk Tickets List -->
|
||||
<div class="mt-6 space-y-4">
|
||||
<div class="mt-4 space-y-2">
|
||||
{#if isLoading}
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500">Cargando tickets en riesgo...</p>
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow">
|
||||
<div class="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-3 text-sm text-gray-600">Cargando tickets en riesgo...</p>
|
||||
</div>
|
||||
{:else if atRiskTickets.length === 0}
|
||||
<div class="bg-white shadow rounded-lg text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="mt-2 text-lg font-medium text-gray-900">¡Todo bajo control!</p>
|
||||
<p class="mt-2 text-base font-medium text-gray-900">Todo bajo control</p>
|
||||
<p class="mt-1 text-sm text-gray-500">No hay tickets en riesgo de violar SLA</p>
|
||||
</div>
|
||||
{:else if filteredTickets.length === 0}
|
||||
<div class="bg-white shadow rounded-lg text-center py-12">
|
||||
<p class="mt-2 text-base font-medium text-gray-900">Sin resultados</p>
|
||||
<p class="mt-1 text-sm text-gray-500">No se encontraron tickets con los filtros aplicados</p>
|
||||
<button
|
||||
on:click={clearFilters}
|
||||
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each atRiskTickets as ticket}
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden border-l-4 {getRiskColor(ticket.risk_percentage)}">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
{#each paginatedTickets as ticket}
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden border-l-3 {getRiskColor(ticket.risk_percentage)} hover:shadow transition-shadow">
|
||||
<div class="px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href="/tickets/{ticket.ticket.id}"
|
||||
class="text-lg font-semibold text-indigo-600 hover:text-indigo-900"
|
||||
class="text-sm font-semibold text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{ticket.ticket.ticket_number}
|
||||
</a>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {getPriorityColor(ticket.ticket.priority)}">
|
||||
{ticket.ticket.priority}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-100 text-gray-700">
|
||||
{getSLATypeLabel(ticket.sla_type)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-900">{ticket.ticket.subject}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-700 line-clamp-1">{ticket.ticket.subject}</p>
|
||||
|
||||
{#if ticket.category}
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
📂 {ticket.category.name}
|
||||
<span class="text-gray-400">
|
||||
(SLA: {ticket.sla_type === 'response' ? ticket.category.sla_response_hours : ticket.category.sla_resolution_hours}h)
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ticket.assigned_to}
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
👤 Asignado a: <span class="text-gray-900">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
|
||||
{#if ticket.category}
|
||||
<span class="truncate">{ticket.category.name}</span>
|
||||
{/if}
|
||||
{#if ticket.assigned_to}
|
||||
<span class="truncate">{ticket.assigned_to.first_name} {ticket.assigned_to.last_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-6 flex-shrink-0 text-right">
|
||||
<div class="text-sm font-medium {getRiskColor(ticket.risk_percentage)} inline-flex items-center px-3 py-1 rounded-full border">
|
||||
{getRiskLabel(ticket.risk_percentage)}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xs font-semibold {getRiskBadgeColor(ticket.risk_percentage)} px-2 py-0.5 rounded">
|
||||
{ticket.risk_percentage.toFixed(0)}%
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="font-semibold text-red-600">
|
||||
Progreso: {ticket.risk_percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
Quedan: <span class="font-medium text-orange-600">{formatHours(ticket.time_remaining_hours)}</span>
|
||||
<div class="text-xs text-gray-600">
|
||||
{formatHours(ticket.time_remaining_hours)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="mt-4">
|
||||
<div class="relative">
|
||||
<div class="overflow-hidden h-2 text-xs flex rounded bg-gray-200">
|
||||
<div
|
||||
style="width: {ticket.risk_percentage}%"
|
||||
class="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0%</span>
|
||||
<span class="text-orange-600 font-medium">{threshold}% (umbral)</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-2">
|
||||
<div class="overflow-hidden h-1 rounded-full bg-gray-200">
|
||||
<div
|
||||
style="width: {ticket.risk_percentage}%"
|
||||
class="h-full transition-all {ticket.risk_percentage >= 95 ? 'bg-red-500' : ticket.risk_percentage >= 90 ? 'bg-orange-500' : ticket.risk_percentage >= 80 ? 'bg-yellow-500' : 'bg-blue-500'}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 px-6 py-3 flex justify-end gap-3">
|
||||
<div class="bg-gray-50 px-3 py-1.5 flex justify-end border-t border-gray-100">
|
||||
<a
|
||||
href="/tickets/{ticket.ticket.id}"
|
||||
class="text-sm font-medium text-indigo-600 hover:text-indigo-900"
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Ver ticket →
|
||||
Ver ticket
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,34 +390,61 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if !isLoading && atRiskTickets.length > 0}
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-4">Resumen</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4 text-center">
|
||||
<!-- Pagination -->
|
||||
{#if !isLoading && totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg shadow">
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
on:click={prevPage}
|
||||
disabled={page === 1}
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
on:click={nextPage}
|
||||
disabled={page === totalPages}
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Total en Riesgo</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{atRiskTickets.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Riesgo Crítico (≥95%)</p>
|
||||
<p class="text-2xl font-semibold text-red-600">
|
||||
{atRiskTickets.filter(t => t.risk_percentage >= 95).length}
|
||||
<p class="text-sm text-gray-700">
|
||||
Mostrando
|
||||
<span class="font-medium">{(page - 1) * perPage + 1}</span>
|
||||
a
|
||||
<span class="font-medium">{Math.min(page * perPage, totalTickets)}</span>
|
||||
de
|
||||
<span class="font-medium">{totalTickets}</span>
|
||||
resultados
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Riesgo Alto (≥90%)</p>
|
||||
<p class="text-2xl font-semibold text-orange-600">
|
||||
{atRiskTickets.filter(t => t.risk_percentage >= 90 && t.risk_percentage < 95).length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Riesgo Medio (≥80%)</p>
|
||||
<p class="text-2xl font-semibold text-yellow-600">
|
||||
{atRiskTickets.filter(t => t.risk_percentage >= 80 && t.risk_percentage < 90).length}
|
||||
</p>
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
<button
|
||||
on:click={prevPage}
|
||||
disabled={page === 1}
|
||||
class="relative inline-flex items-center rounded-l-md px-3 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300">
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
<button
|
||||
on:click={nextPage}
|
||||
disabled={page === totalPages}
|
||||
class="relative inline-flex items-center rounded-r-md px-3 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
let violations: any[] = [];
|
||||
let total = 0;
|
||||
let page = 1;
|
||||
let perPage = 20;
|
||||
let perPage = 10;
|
||||
let totalPages = 0;
|
||||
|
||||
// Filtros
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
function getSLATypeColor(type: string): string {
|
||||
return type === 'response' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
|
||||
return type === 'response' ? 'bg-orange-100 text-orange-800' : 'bg-red-100 text-red-800';
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
@@ -130,7 +130,7 @@
|
||||
id="slaType"
|
||||
bind:value={slaTypeFilter}
|
||||
on:change={handleFilterChange}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="response">Respuesta</option>
|
||||
@@ -144,7 +144,7 @@
|
||||
id="category"
|
||||
bind:value={categoryFilter}
|
||||
on:change={handleFilterChange}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{#each categories as cat}
|
||||
@@ -159,7 +159,7 @@
|
||||
id="priority"
|
||||
bind:value={priorityFilter}
|
||||
on:change={handleFilterChange}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
<option value="LOW">Baja</option>
|
||||
@@ -186,22 +186,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="mt-6 bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-700">
|
||||
<strong>{total}</strong> violaciones activas encontradas
|
||||
<div class="mt-6 bg-red-50 border-l-4 border-red-500 p-4 rounded-lg">
|
||||
<p class="text-sm text-red-800">
|
||||
<strong class="font-bold">{total}</strong> {total === 1 ? 'violación activa' : 'violaciones activas'} encontradas
|
||||
{#if total > 0}
|
||||
- Requieren atención inmediata
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Violations Table -->
|
||||
@@ -238,18 +230,16 @@
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500">Cargando violaciones...</p>
|
||||
<td colspan="7" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-4 text-sm text-gray-600 font-medium">Cargando violaciones...</p>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if violations.length === 0}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">Excelente! No hay violaciones de SLA activas</p>
|
||||
<td colspan="7" class="text-center py-12">
|
||||
<p class="mt-3 text-base font-medium text-gray-900">¡Excelente trabajo!</p>
|
||||
<p class="mt-1 text-sm text-gray-600">No hay violaciones de SLA activas</p>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
@@ -259,7 +249,7 @@
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
href="/tickets/{violation.ticket.id}"
|
||||
class="font-medium text-indigo-600 hover:text-indigo-900"
|
||||
class="font-medium text-blue-700 hover:text-blue-900"
|
||||
>
|
||||
{violation.ticket.ticket_number}
|
||||
</a>
|
||||
@@ -290,11 +280,14 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<span class="font-semibold text-red-600">
|
||||
{formatHours(violation.hours_overdue)}
|
||||
</span>
|
||||
<div class="text-xs text-gray-500">
|
||||
vencido
|
||||
<div>
|
||||
<span class="font-bold text-red-600 text-base">
|
||||
{formatHours(violation.hours_overdue)}
|
||||
</span>
|
||||
<div class="text-xs text-gray-600">
|
||||
vencido
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
@@ -314,7 +307,7 @@
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<a
|
||||
href="/tickets/{violation.ticket.id}"
|
||||
class="text-indigo-600 hover:text-indigo-900"
|
||||
class="text-blue-700 hover:text-blue-900"
|
||||
>
|
||||
Ver ticket →
|
||||
</a>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
|
||||
>
|
||||
Nuevo Sistema
|
||||
</button>
|
||||
@@ -100,12 +100,12 @@
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{system.name}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{system.description || '-'}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={system.is_active} class:text-green-800={system.is_active} class:bg-red-100={!system.is_active} class:text-red-800={!system.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {system.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
|
||||
{system.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(system)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button on:click={() => openEditModal(system)} class="text-blue-700 hover:text-blue-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -122,24 +122,24 @@
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
|
||||
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
|
||||
>
|
||||
Nuevo Cliente
|
||||
</button>
|
||||
@@ -143,7 +143,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleTenantStatus(tenant)}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {tenant.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
|
||||
role="switch"
|
||||
aria-checked={tenant.status === 'active'}
|
||||
>
|
||||
@@ -153,13 +153,13 @@
|
||||
</button>
|
||||
|
||||
<!-- Badge de Estado -->
|
||||
<span class:bg-green-100={tenant.status === 'active'} class:text-green-800={tenant.status === 'active'} class:bg-yellow-100={tenant.status === 'suspended'} class:text-yellow-800={tenant.status === 'suspended'} class:bg-red-100={tenant.status === 'inactive'} class:text-red-800={tenant.status === 'inactive'} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {tenant.status === 'active' ? 'bg-gray-50 text-green-700 border-green-200' : tenant.status === 'suspended' ? 'bg-gray-50 text-orange-700 border-orange-200' : 'bg-gray-50 text-red-700 border-red-200'}">
|
||||
{tenant.status === 'active' ? 'Activo' : tenant.status === 'suspended' ? 'Suspendido' : 'Inactivo'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button on:click={() => openEditModal(tenant)} class="text-blue-700 hover:text-blue-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -176,28 +176,28 @@
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-gray-700">Slug (Identificador)</label>
|
||||
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
<p class="text-xs text-gray-500 mt-1">Usado en URLs y subdominios.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="domain" class="block text-sm font-medium text-gray-700">Dominio Personalizado</label>
|
||||
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="contact_email" class="block text-sm font-medium text-gray-700">Email de Contacto</label>
|
||||
<input type="email" id="contact_email" bind:value={formData.contact_email} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="email" id="contact_email" bind:value={formData.contact_email} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="contact_phone" class="block text-sm font-medium text-gray-700">Teléfono de Contacto</label>
|
||||
<input type="text" id="contact_phone" bind:value={formData.contact_phone} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="contact_phone" bind:value={formData.contact_phone} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -208,7 +208,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => formData.status = formData.status === 'active' ? 'inactive' : 'active'}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {formData.status === 'active' ? 'bg-green-600' : 'bg-gray-300'}"
|
||||
role="switch"
|
||||
aria-checked={formData.status === 'active'}
|
||||
>
|
||||
@@ -229,7 +229,7 @@
|
||||
type="checkbox"
|
||||
checked={formData.status === 'suspended'}
|
||||
on:change={(e) => formData.status = e.target.checked ? 'suspended' : 'active'}
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 h-4 w-4"
|
||||
class="rounded border-gray-300 text-blue-700 focus:ring-blue-500 h-4 w-4"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-600">Marcar como suspendido temporalmente</span>
|
||||
</label>
|
||||
@@ -238,10 +238,10 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
{#if editingTenant}
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
<div class="flex gap-5 px-4 py-5 mx-auto max-w-full sm:px-6 lg:px-8">
|
||||
<aside class="w-60 flex-shrink-0 space-y-4">
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
|
||||
<div class="bg-indigo-600 px-4 py-3">
|
||||
<div class="bg-blue-700 px-4 py-3">
|
||||
<h2 class="text-sm font-semibold text-white tracking-wide">Organización</h2>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@@ -320,7 +320,7 @@
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 flex items-center justify-between text-sm transition-colors
|
||||
{filterTenantId === ''
|
||||
? 'bg-indigo-50 text-indigo-700 font-semibold'
|
||||
? 'bg-blue-50 text-blue-700 font-semibold'
|
||||
: 'text-gray-700 hover:bg-gray-50'}"
|
||||
on:click={() => {
|
||||
filterTenantId = '';
|
||||
@@ -338,7 +338,7 @@
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 flex items-center justify-between text-sm transition-colors
|
||||
{filterTenantId === tenant.id
|
||||
? 'bg-indigo-50 text-indigo-700 font-semibold'
|
||||
? 'bg-blue-50 text-blue-700 font-semibold'
|
||||
: 'text-gray-700 hover:bg-gray-50'}"
|
||||
on:click={() => {
|
||||
filterTenantId = tenant.id;
|
||||
@@ -347,7 +347,7 @@
|
||||
>
|
||||
<span class="truncate pr-1">{tenant.name}</span>
|
||||
<span
|
||||
class="text-xs bg-indigo-100 text-indigo-600 rounded-full px-2 py-0.5 font-medium flex-shrink-0"
|
||||
class="text-xs bg-gray-100 text-blue-700 rounded-full px-2 py-0.5 font-medium flex-shrink-0 border border-gray-200"
|
||||
>
|
||||
{tenantCounts[tenant.id] ?? 0}
|
||||
</span>
|
||||
@@ -366,7 +366,7 @@
|
||||
<select
|
||||
bind:value={filterStatus}
|
||||
on:change={() => (categoryPages = {})}
|
||||
class="block w-full rounded-md border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500 border px-2 py-1.5"
|
||||
class="block w-full rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 border px-2 py-1.5"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{#each STATUSES as s}
|
||||
@@ -380,7 +380,7 @@
|
||||
<select
|
||||
bind:value={filterPriority}
|
||||
on:change={() => (categoryPages = {})}
|
||||
class="block w-full rounded-md border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500 border px-2 py-1.5"
|
||||
class="block w-full rounded-md border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 border px-2 py-1.5"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{#each PRIORITIES as p}
|
||||
@@ -398,7 +398,7 @@
|
||||
searchQuery = '';
|
||||
categoryPages = {};
|
||||
}}
|
||||
class="w-full text-xs text-indigo-600 hover:text-indigo-800 font-medium text-center pt-1"
|
||||
class="w-full text-xs text-blue-700 hover:text-blue-800 font-medium text-center pt-1"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
@@ -430,107 +430,44 @@
|
||||
</div>
|
||||
<button
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md shadow-sm hover:bg-indigo-700 transition-colors"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md shadow-sm hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
+ Nuevo Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4 flex items-center gap-3">
|
||||
<div class="bg-gray-100 rounded-lg p-2">
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 font-medium">Total</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{kpiTotal}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 font-medium">Total</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{kpiTotal}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-red-200 shadow-sm p-4 flex items-center gap-3">
|
||||
<div class="bg-red-50 rounded-lg p-2">
|
||||
<svg class="h-5 w-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-red-500 font-medium">Urgentes</p>
|
||||
<p class="text-2xl font-bold text-red-600">{kpiUrgent}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-red-200 shadow-sm p-4">
|
||||
<p class="text-xs text-red-500 font-medium">Urgentes</p>
|
||||
<p class="text-2xl font-bold text-red-600">{kpiUrgent}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-blue-200 shadow-sm p-4 flex items-center gap-3">
|
||||
<div class="bg-blue-50 rounded-lg p-2">
|
||||
<svg class="h-5 w-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-blue-500 font-medium">Activos</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{kpiActive}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-blue-200 shadow-sm p-4">
|
||||
<p class="text-xs text-blue-500 font-medium">Activos</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{kpiActive}</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white rounded-xl border border-green-200 shadow-sm p-4 flex items-center gap-3"
|
||||
>
|
||||
<div class="bg-green-50 rounded-lg p-2">
|
||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-green-500 font-medium">Resueltos</p>
|
||||
<p class="text-2xl font-bold text-green-600">{kpiResolved}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-green-200 shadow-sm p-4">
|
||||
<p class="text-xs text-green-500 font-medium">Resueltos</p>
|
||||
<p class="text-2xl font-bold text-green-600">{kpiResolved}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por número, asunto, usuario..."
|
||||
bind:value={searchQuery}
|
||||
on:input={() => (categoryPages = {})}
|
||||
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
|
||||
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center py-16 text-gray-400 text-sm">
|
||||
<svg class="animate-spin h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="animate-spin h-5 w-5 mr-2 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
@@ -568,45 +505,32 @@
|
||||
/>
|
||||
<span class="font-semibold text-gray-800 text-sm">{group.name}</span>
|
||||
<span
|
||||
class="text-xs bg-indigo-100 text-indigo-700 font-medium rounded-full px-2 py-0.5"
|
||||
class="text-xs bg-gray-100 text-blue-700 font-medium rounded-full px-2 py-0.5 border border-gray-200"
|
||||
>
|
||||
{group.tickets.length}
|
||||
</span>
|
||||
{#if group.urgCount > 0}
|
||||
<span
|
||||
class="text-xs bg-red-100 text-red-700 font-semibold rounded-full px-2 py-0.5"
|
||||
class="text-xs bg-gray-50 text-red-700 font-semibold rounded-full px-2 py-0.5 border border-red-200"
|
||||
>
|
||||
? {group.urgCount} urgente{group.urgCount > 1 ? 's' : ''}
|
||||
{group.urgCount} urgente{group.urgCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if group.highCount > 0}
|
||||
<span
|
||||
class="text-xs bg-orange-100 text-orange-700 font-medium rounded-full px-2 py-0.5"
|
||||
class="text-xs bg-gray-50 text-orange-700 font-medium rounded-full px-2 py-0.5 border border-orange-200"
|
||||
>
|
||||
? {group.highCount} alta{group.highCount > 1 ? 's' : ''}
|
||||
{group.highCount} alta{group.highCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if group.closedCount > 0}
|
||||
<span
|
||||
class="text-xs bg-gray-100 text-gray-500 font-medium rounded-full px-2 py-0.5"
|
||||
>
|
||||
? {group.closedCount} cerrado{group.closedCount > 1 ? 's' : ''}
|
||||
{group.closedCount} cerrado{group.closedCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 transition-transform {isCollapsed ? '' : 'rotate-180'}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if !isCollapsed}
|
||||
@@ -653,7 +577,7 @@
|
||||
<tr
|
||||
class="cursor-pointer transition-all {rowBorderClass(ticket)} {isClosed
|
||||
? 'opacity-55 hover:opacity-80 bg-gray-50/50'
|
||||
: 'hover:bg-indigo-50/60'}"
|
||||
: 'hover:bg-blue-50/60'}"
|
||||
on:click={() => goto(`/tickets/${ticket.id}`)}
|
||||
>
|
||||
<td
|
||||
@@ -709,7 +633,7 @@
|
||||
<td class="px-4 py-2.5 whitespace-nowrap text-right">
|
||||
<button
|
||||
on:click|stopPropagation={() => openEditModal(ticket)}
|
||||
class="text-indigo-600 hover:text-indigo-900 font-medium mr-2"
|
||||
class="text-blue-700 hover:text-blue-900 font-medium mr-2"
|
||||
>Editar</button
|
||||
>
|
||||
<button
|
||||
@@ -745,7 +669,7 @@
|
||||
on:click={() => setPage(catId, p)}
|
||||
class="px-2.5 py-1 rounded text-xs border font-medium transition-colors
|
||||
{p === page
|
||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||
? 'bg-blue-700 border-blue-700 text-white'
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-100'}">{p}</button
|
||||
>
|
||||
{/each}
|
||||
@@ -776,7 +700,7 @@
|
||||
type="text"
|
||||
bind:value={formData.subject}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
placeholder="Breve descripción del problema"
|
||||
/>
|
||||
</div>
|
||||
@@ -786,7 +710,7 @@
|
||||
bind:value={formData.description}
|
||||
required
|
||||
rows="4"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
placeholder="Describe el problema en detalle..."
|
||||
/>
|
||||
</div>
|
||||
@@ -830,7 +754,7 @@
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-3 mt-5">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-sm font-medium text-white hover:bg-indigo-700 sm:col-start-2"
|
||||
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-sm font-medium text-white hover:bg-blue-800 sm:col-start-2"
|
||||
>
|
||||
Crear Ticket
|
||||
</button>
|
||||
@@ -896,7 +820,7 @@
|
||||
<div class="sm:grid sm:grid-cols-2 sm:gap-3 mt-5">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-sm font-medium text-white hover:bg-indigo-700 sm:col-start-2"
|
||||
class="w-full justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-sm font-medium text-white hover:bg-blue-800 sm:col-start-2"
|
||||
>
|
||||
Guardar Cambios
|
||||
</button>
|
||||
@@ -913,20 +837,7 @@
|
||||
|
||||
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => (showDeleteModal = false)}>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-red-50 border border-red-200 rounded-md p-4 flex gap-3">
|
||||
<svg
|
||||
class="h-5 w-5 text-red-400 mt-0.5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-800">¿Eliminar este ticket?</h3>
|
||||
<p class="text-sm text-red-700 mt-1">
|
||||
|
||||
@@ -143,16 +143,14 @@
|
||||
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
{#if isLoading}
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-700" />
|
||||
<p class="mt-2 text-gray-600">Cargando ticket...</p>
|
||||
</div>
|
||||
{:else if ticket}
|
||||
<!-- Breadcrumb -->
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
|
||||
<a href="/tickets" class="hover:text-indigo-600">Tickets</a>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<a href="/tickets" class="hover:text-blue-700">Tickets</a>
|
||||
<span>/</span>
|
||||
<span>#{ticket.ticket_number || ticket.id.substring(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
@@ -222,21 +220,9 @@
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-indigo-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs font-bold text-blue-700">ADJ</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
@@ -255,17 +241,10 @@
|
||||
</div>
|
||||
<button
|
||||
on:click={() => handleDownloadAttachment(attachment)}
|
||||
class="inline-flex items-center p-2 text-sm font-medium text-indigo-600 hover:bg-indigo-50 rounded-md transition-colors"
|
||||
class="inline-flex items-center px-2 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 rounded-md transition-colors"
|
||||
title="Descargar {attachment.original_filename}"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Descargar
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -289,15 +268,15 @@
|
||||
{#each comments as comment}
|
||||
<div class="flex space-x-3">
|
||||
<div
|
||||
class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
class="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<span class="text-indigo-600 text-xs font-medium">
|
||||
<span class="text-blue-700 text-xs font-medium">
|
||||
{comment.author_name
|
||||
? comment.author_name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
: '??'}
|
||||
: 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -309,7 +288,7 @@
|
||||
{formatDate(comment.created_at)}
|
||||
</span>
|
||||
{#if comment.is_internal}
|
||||
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
|
||||
<span class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200">
|
||||
Interno
|
||||
</span>
|
||||
{/if}
|
||||
@@ -328,7 +307,7 @@
|
||||
<form on:submit|preventDefault={handleAddComment} class="space-y-4">
|
||||
<textarea
|
||||
rows="4"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2"
|
||||
placeholder="Escribe tu comentario o respuesta..."
|
||||
bind:value={newComment}
|
||||
disabled={isSubmittingComment}
|
||||
@@ -337,7 +316,7 @@
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
disabled={isSubmittingComment || !newComment.trim()}
|
||||
>
|
||||
{#if isSubmittingComment}
|
||||
@@ -398,7 +377,7 @@
|
||||
<!-- SLA Information -->
|
||||
{#if ticket.sla_response_due || ticket.sla_resolution_due}
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">⏱️ SLA (Acuerdos de Nivel de Servicio)</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">SLA (Acuerdos de Nivel de Servicio)</h4>
|
||||
|
||||
{#if ticket.sla_response_due}
|
||||
<div class="mb-3">
|
||||
@@ -406,16 +385,16 @@
|
||||
<dd class="text-sm text-gray-900 mt-1">
|
||||
{formatDate(ticket.sla_response_due)}
|
||||
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
⚠️ Vencido
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
|
||||
Vencido
|
||||
</span>
|
||||
{:else if ticket.sla_response_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
✓ Cumplido
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
|
||||
Cumplido
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
⏳ En plazo
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
|
||||
En plazo
|
||||
</span>
|
||||
{/if}
|
||||
</dd>
|
||||
@@ -428,16 +407,16 @@
|
||||
<dd class="text-sm text-gray-900 mt-1">
|
||||
{formatDate(ticket.sla_resolution_due)}
|
||||
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
⚠️ Vencido
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
|
||||
Vencido
|
||||
</span>
|
||||
{:else if ticket.sla_resolution_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
✓ Cumplido
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
|
||||
Cumplido
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
⏳ En plazo
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
|
||||
En plazo
|
||||
</span>
|
||||
{/if}
|
||||
</dd>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreateModal}
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-700 border border-transparent rounded-md shadow-sm hover:bg-blue-800 sm:w-auto"
|
||||
>
|
||||
Nuevo Usuario
|
||||
</button>
|
||||
@@ -154,12 +154,12 @@
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{getTenantName(user.tenant_id)}</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<span class:bg-green-100={user.is_active} class:text-green-800={user.is_active} class:bg-red-100={!user.is_active} class:text-red-800={!user.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 border {user.is_active ? 'bg-gray-50 text-green-700 border-green-200' : 'bg-gray-50 text-red-700 border-red-200'}">
|
||||
{user.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button on:click={() => openEditModal(user)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
|
||||
<button on:click={() => openEditModal(user)} class="text-blue-700 hover:text-blue-900">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -177,27 +177,27 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
|
||||
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
|
||||
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
|
||||
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
|
||||
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
{#each ROLES as role}
|
||||
<option value={role.value}>{role.label}</option>
|
||||
{/each}
|
||||
@@ -206,7 +206,7 @@
|
||||
|
||||
<div>
|
||||
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
|
||||
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm border p-2">
|
||||
<option value="">-- Ninguno (Usuario Interno) --</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
@@ -215,15 +215,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-blue-700 focus:ring-blue-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
|
||||
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm">
|
||||
Guardar
|
||||
</button>
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,27 +6,23 @@ export default {
|
||||
'bg-red-600',
|
||||
'bg-blue-600',
|
||||
'bg-green-600',
|
||||
'bg-indigo-600',
|
||||
'bg-orange-600',
|
||||
'bg-yellow-500',
|
||||
'bg-yellow-600',
|
||||
'bg-gray-600',
|
||||
// Colores de severidad/riesgo
|
||||
'bg-red-50',
|
||||
'bg-red-100',
|
||||
'bg-red-800',
|
||||
'bg-orange-50',
|
||||
'bg-orange-100',
|
||||
'bg-orange-300',
|
||||
'bg-orange-600',
|
||||
'bg-orange-700',
|
||||
'bg-orange-800',
|
||||
'bg-yellow-100',
|
||||
'bg-yellow-300',
|
||||
'bg-yellow-800',
|
||||
'bg-blue-50',
|
||||
'bg-blue-100',
|
||||
'bg-blue-300',
|
||||
'bg-blue-800',
|
||||
'bg-green-50',
|
||||
'bg-green-100',
|
||||
'bg-green-300',
|
||||
'bg-green-800',
|
||||
@@ -34,7 +30,6 @@ export default {
|
||||
'border-red-300',
|
||||
'border-blue-200',
|
||||
'border-orange-300',
|
||||
'border-yellow-300',
|
||||
'border-blue-300',
|
||||
'border-green-300',
|
||||
// Text colors
|
||||
@@ -45,30 +40,26 @@ export default {
|
||||
'text-orange-400',
|
||||
'text-orange-600',
|
||||
'text-orange-800',
|
||||
'text-yellow-400',
|
||||
'text-yellow-600',
|
||||
'text-yellow-800',
|
||||
'text-blue-400',
|
||||
'text-blue-600',
|
||||
'text-blue-700',
|
||||
'text-blue-800',
|
||||
'text-blue-900',
|
||||
'text-green-600',
|
||||
'text-green-800',
|
||||
'text-indigo-600',
|
||||
'text-white',
|
||||
// Hover states
|
||||
'hover:bg-red-50',
|
||||
'hover:bg-red-700',
|
||||
'hover:bg-orange-700',
|
||||
'hover:bg-blue-50',
|
||||
'hover:bg-blue-700',
|
||||
'hover:bg-indigo-50',
|
||||
'hover:bg-indigo-700',
|
||||
'hover:bg-blue-800',
|
||||
'hover:text-red-700',
|
||||
// Focus rings
|
||||
'focus:ring-red-500',
|
||||
'focus:ring-orange-500',
|
||||
'focus:ring-blue-500',
|
||||
'focus:ring-indigo-500',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
@@ -84,7 +75,46 @@ export default {
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
|
||||
Este directorio contiene scripts para desarrollo y despliegue del sistema.
|
||||
|
||||
## 🔒 Scripts de Seguridad y Testing
|
||||
|
||||
### security-test-data.ps1
|
||||
```powershell
|
||||
# Generar datos de prueba para análisis de seguridad
|
||||
.\scripts\security-test-data.ps1
|
||||
|
||||
# Limpiar datos de prueba
|
||||
.\scripts\security-test-data.ps1 limpiar
|
||||
```
|
||||
|
||||
**¿Qué hace?**
|
||||
- Genera 105 logs de auditoría en Docker (backend container)
|
||||
- Simula 3 amenazas: 1 CRITICAL (55 eliminaciones) + 2 HIGH (25 logins fallidos + 5 cambios privilegios)
|
||||
- Permite probar toda la funcionalidad del análisis de seguridad
|
||||
- Ver página: http://localhost:3001/audit/security
|
||||
|
||||
**Escenarios:**
|
||||
- Sin parámetro o `generar`: Crea amenazas de prueba
|
||||
- `limpiar`: Elimina TODOS los logs de las últimas 24h ⚠️
|
||||
|
||||
**Comandos equivalentes en Docker:**
|
||||
```powershell
|
||||
# Generar
|
||||
docker exec servicemanager-backend python scripts/generate_security_test_data.py
|
||||
|
||||
# Limpiar
|
||||
docker exec servicemanager-backend python scripts/generate_security_test_data.py cleanup
|
||||
```
|
||||
|
||||
### 📊 Datos de Prueba SLA Management
|
||||
|
||||
```powershell
|
||||
# Generar tickets con diferentes estados de SLA
|
||||
docker exec servicemanager-backend python scripts/generate_sla_test_data.py
|
||||
|
||||
# Limpiar tickets de prueba
|
||||
docker exec servicemanager-backend python scripts/generate_sla_test_data.py cleanup
|
||||
```
|
||||
|
||||
**¿Qué genera?**
|
||||
- 71 tickets totales con diferentes estados de SLA
|
||||
- **Response SLA**: 15 cumplidos + 8 violados + 10 en riesgo
|
||||
- **Resolution SLA**: 20 cumplidos + 6 violados + 12 en riesgo
|
||||
- Distribuidos por categorías (Soporte, Facturación, Incidentes, Consultas)
|
||||
- Distribuidos por prioridades (LOW, MEDIUM, HIGH, URGENT)
|
||||
- Ver resultados: http://localhost:3001/sla
|
||||
|
||||
## Scripts de Desarrollo
|
||||
|
||||
### setup-dev.sh / setup-dev.ps1
|
||||
|
||||
54
scripts/security-test-data.ps1
Normal file
54
scripts/security-test-data.ps1
Normal file
@@ -0,0 +1,54 @@
|
||||
# Script PowerShell para gestionar datos de prueba de seguridad
|
||||
# Uso: .\scripts\security-test-data.ps1 [generar|limpiar]
|
||||
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet("generar", "limpiar", "")]
|
||||
[string]$Accion = "generar"
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host " GENERADOR DE DATOS DE PRUEBA - ANÁLISIS SEGURIDAD" -ForegroundColor Cyan
|
||||
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Verificar que el contenedor esté corriendo
|
||||
$container = docker ps --filter "name=servicemanager-backend" --format "{{.Names}}"
|
||||
|
||||
if (-not $container) {
|
||||
Write-Host "❌ ERROR: El contenedor 'servicemanager-backend' no está corriendo" -ForegroundColor Red
|
||||
Write-Host " Ejecuta primero: docker-compose up -d" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Contenedor backend encontrado: $container" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
if ($Accion -eq "limpiar") {
|
||||
Write-Host "🧹 LIMPIANDO datos de prueba..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
docker exec servicemanager-backend python scripts/generate_security_test_data.py cleanup
|
||||
} else {
|
||||
Write-Host "🚀 GENERANDO datos de prueba..." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
docker exec servicemanager-backend python scripts/generate_security_test_data.py
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
|
||||
if ($Accion -eq "limpiar") {
|
||||
Write-Host "✅ Limpieza completada" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "✅ Generación completada" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "📱 Accede a la página de seguridad:" -ForegroundColor Cyan
|
||||
Write-Host " http://localhost:3001/audit/security" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "💡 Para limpiar los datos:" -ForegroundColor Yellow
|
||||
Write-Host " .\scripts\security-test-data.ps1 limpiar" -ForegroundColor White
|
||||
}
|
||||
|
||||
Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
67
test_critical_sync.ps1
Normal file
67
test_critical_sync.ps1
Normal file
@@ -0,0 +1,67 @@
|
||||
# Test para verificar sincronización de incidentes críticos
|
||||
# entre módulo Auditoría y módulo Seguridad
|
||||
|
||||
Write-Host "`n=== TEST: Sincronización de Incidentes Críticos ===" -ForegroundColor Cyan
|
||||
|
||||
# 1. Login
|
||||
Write-Host "`n1. Autenticando..." -ForegroundColor Yellow
|
||||
$loginBody = @{
|
||||
email = "admin@aduanasoft.com"
|
||||
password = "admin123"
|
||||
tenant_slug = "aduanasoft"
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$loginResponse = Invoke-RestMethod -Uri "http://localhost/api/v1/auth/login" -Method Post -Body $loginBody -ContentType "application/json"
|
||||
$token = $loginResponse.access_token
|
||||
$headers = @{
|
||||
"Authorization" = "Bearer $token"
|
||||
}
|
||||
Write-Host "✓ Autenticado correctamente" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "✗ Error en login: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 2. Obtener stats del módulo Auditoría
|
||||
Write-Host "`n2. Consultando módulo Auditoría (/audit/stats)..." -ForegroundColor Yellow
|
||||
try {
|
||||
$auditStats = Invoke-RestMethod -Uri "http://localhost/api/v1/audit/stats" -Headers $headers
|
||||
$auditCritical = $auditStats.critical_today
|
||||
Write-Host "✓ Incidentes críticos en Auditoría: $auditCritical" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "✗ Error consultando Auditoría: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 3. Obtener stats del módulo Seguridad
|
||||
Write-Host "`n3. Consultando módulo Seguridad (/audit/security/analysis)..." -ForegroundColor Yellow
|
||||
try {
|
||||
$securityAnalysis = Invoke-RestMethod -Uri "http://localhost/api/v1/audit/security/analysis?hours=24" -Headers $headers
|
||||
$securityCritical = $securityAnalysis.critical_actions_count
|
||||
Write-Host "✓ Acciones críticas en Seguridad: $securityCritical" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "✗ Error consultando Seguridad: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 4. Comparar resultados
|
||||
Write-Host "`n4. Comparación:" -ForegroundColor Yellow
|
||||
Write-Host " Auditoría: $auditCritical incidentes críticos" -ForegroundColor White
|
||||
Write-Host " Seguridad: $securityCritical acciones críticas" -ForegroundColor White
|
||||
|
||||
if ($auditCritical -eq $securityCritical) {
|
||||
Write-Host "`n✓ SINCRONIZADOS: Ambos módulos reportan el mismo número" -ForegroundColor Green
|
||||
Write-Host " Los contadores están alineados correctamente." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`n✗ DESINCRONIZADOS: Los números no coinciden" -ForegroundColor Red
|
||||
Write-Host " Diferencia: $([Math]::Abs($auditCritical - $securityCritical)) registros" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host "`n=== Detalles adicionales ===" -ForegroundColor Cyan
|
||||
Write-Host "Amenazas detectadas: $($securityAnalysis.total_threats_detected)" -ForegroundColor White
|
||||
Write-Host "Nivel de riesgo: $($securityAnalysis.overall_risk_level)" -ForegroundColor White
|
||||
Write-Host "Login fallidos: $($securityAnalysis.failed_login_attempts)" -ForegroundColor White
|
||||
Write-Host "IPs sospechosas: $($securityAnalysis.suspicious_ips_count)" -ForegroundColor White
|
||||
|
||||
Write-Host "`n=== Test completado ===" -ForegroundColor Cyan
|
||||
Reference in New Issue
Block a user