changes: Fixed widget calculation with networkds selection

This commit is contained in:
2025-11-18 17:14:02 -06:00
parent d8734c30b6
commit 08a6b91687
10 changed files with 2312 additions and 25 deletions

1309
master_app_plan.md Normal file

File diff suppressed because it is too large Load Diff

913
splash_master_update.md Normal file
View File

@@ -0,0 +1,913 @@
# SplashPage Updates para OSB Master Integration
## Resumen
Este documento describe las modificaciones necesarias en las instancias de SplashPage para integrarse con el sistema OSB Master.
---
## Fase 1: Modificaciones en SplashPage Backend
### 1.1 Crear API de Métricas
#### InstanceMetricsController.cs
**Ubicación**: `src/SplashPage.Web.Host/Controllers/InstanceMetricsController.cs`
**Endpoints a implementar**:
```csharp
[Route("api/[controller]")]
public class InstanceMetricsController : SplashPageControllerBase
{
private readonly IRepository<SplashUser> _userRepo;
private readonly IRepository<SplashUserConnection> _connectionRepo;
private readonly IRepository<SplashMerakiNetwork> _networkRepo;
private readonly IRepository<SplashEmailTemplate> _emailTemplateRepo;
private readonly IRepository<SplashScheduledEmail> _scheduledEmailRepo;
private readonly ITenantCache _tenantCache;
[HttpGet("health")]
[AbpAllowAnonymous]
public async Task<IActionResult> GetHealth()
{
// Basic health check - no auth required
return Ok(new {
status = "healthy",
timestamp = DateTime.UtcNow,
version = AppVersionHelper.Version
});
}
[HttpGet("metrics")]
public async Task<InstanceMetricsDto> GetMetrics(
[FromHeader(Name = "X-Master-Api-Key")] string apiKey)
{
// Validate API key
var validKey = Environment.GetEnvironmentVariable("MASTER_API_KEY");
if (string.IsNullOrEmpty(apiKey) || apiKey != validKey)
throw new AbpAuthorizationException("Invalid API key");
var tenantId = AbpSession.TenantId ?? 0;
// Gather metrics (use service layer)
var metrics = await _metricsService.GetInstanceMetrics(tenantId);
return metrics;
}
}
```
---
### 1.2 Crear DTO de Métricas
#### InstanceMetricsDto.cs
**Ubicación**: `src/SplashPage.Application/Metrics/Dto/InstanceMetricsDto.cs`
```csharp
public class InstanceMetricsDto
{
// Instance Info
public string InstanceId { get; set; }
public string TenantName { get; set; }
public int TenantId { get; set; }
public DateTime LastChecked { get; set; }
public string AppVersion { get; set; }
public string Environment { get; set; }
// User Metrics
public int TotalUsers { get; set; }
public int ActiveUsersLast24Hours { get; set; }
public int ActiveUsersLast7Days { get; set; }
public int OnlineUsersNow { get; set; }
public DateTime? LastUserLogin { get; set; }
public string LastUserEmail { get; set; }
// Admin Access
public DateTime? LastAdminAccess { get; set; }
public string LastAdminEmail { get; set; }
// Device/Network Metrics
public int TotalNetworks { get; set; }
public int TotalAccessPoints { get; set; }
public int MerakiOrganizationCount { get; set; }
// Email Metrics
public int EmailTemplatesCount { get; set; }
public int PendingScheduledEmails { get; set; }
public int EmailsSentLast24Hours { get; set; }
public DateTime? LastEmailSent { get; set; }
// System Health
public bool DatabaseConnected { get; set; }
public bool MerakiApiHealthy { get; set; }
public double DatabaseResponseTimeMs { get; set; }
}
```
---
### 1.3 Implementar Service de Métricas
#### InstanceMetricsAppService.cs
**Ubicación**: `src/SplashPage.Application/Metrics/InstanceMetricsAppService.cs`
```csharp
public class InstanceMetricsAppService : SplashPageAppServiceBase
{
private readonly IRepository<SplashUser> _userRepo;
private readonly IRepository<SplashUserConnection> _connectionRepo;
private readonly IRepository<SplashMerakiNetwork> _networkRepo;
private readonly IRepository<SplashMerakiDevice> _deviceRepo;
private readonly IRepository<SplashMerakiOrganization> _orgRepo;
private readonly IRepository<SplashEmailTemplate> _emailTemplateRepo;
private readonly IRepository<SplashScheduledEmail> _scheduledEmailRepo;
private readonly ITenantCache _tenantCache;
private readonly IMemoryCache _cache;
public async Task<InstanceMetricsDto> GetInstanceMetrics(int tenantId)
{
// Check cache first (1 minute cache)
var cacheKey = $"InstanceMetrics_{tenantId}";
if (_cache.TryGetValue(cacheKey, out InstanceMetricsDto cachedMetrics))
{
return cachedMetrics;
}
var metrics = new InstanceMetricsDto
{
InstanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "unknown",
TenantId = tenantId,
TenantName = _tenantCache.Get(tenantId)?.TenancyName ?? "Unknown",
LastChecked = DateTime.UtcNow,
AppVersion = AppVersionHelper.Version,
Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"
};
// Run queries in parallel for better performance
var tasks = new[]
{
GetUserMetricsAsync(tenantId, metrics),
GetDeviceMetricsAsync(tenantId, metrics),
GetEmailMetricsAsync(tenantId, metrics),
GetSystemHealthAsync(metrics)
};
await Task.WhenAll(tasks);
// Cache for 1 minute
_cache.Set(cacheKey, metrics, TimeSpan.FromMinutes(1));
return metrics;
}
private async Task GetUserMetricsAsync(int tenantId, InstanceMetricsDto metrics)
{
// Total users
metrics.TotalUsers = await _userRepo.CountAsync(u => u.TenantId == tenantId);
// Active users last 24 hours
var last24h = DateTime.UtcNow.AddHours(-24);
metrics.ActiveUsersLast24Hours = await _connectionRepo.CountAsync(
c => c.TenantId == tenantId && c.LastSeen >= last24h
);
// Active users last 7 days
var last7d = DateTime.UtcNow.AddDays(-7);
metrics.ActiveUsersLast7Days = await _connectionRepo.CountAsync(
c => c.TenantId == tenantId && c.LastSeen >= last7d
);
// Online users now
metrics.OnlineUsersNow = await _connectionRepo.CountAsync(
c => c.TenantId == tenantId && c.Status == "Online"
);
// Last user login
var lastConnection = await _connectionRepo.GetAll()
.Where(c => c.TenantId == tenantId)
.OrderByDescending(c => c.LastSeen)
.FirstOrDefaultAsync();
if (lastConnection != null)
{
metrics.LastUserLogin = lastConnection.LastSeen;
var user = await _userRepo.GetAsync(lastConnection.SplashUserId);
metrics.LastUserEmail = user?.Email;
}
// Last admin access - from AbpUserLoginAttempts or AuditLogs
// This would need to query the ABP audit logs for admin users
// Implementation depends on your specific audit logging setup
}
private async Task GetDeviceMetricsAsync(int tenantId, InstanceMetricsDto metrics)
{
metrics.TotalNetworks = await _networkRepo.CountAsync(n => n.TenantId == tenantId);
metrics.TotalAccessPoints = await _deviceRepo.CountAsync(
d => d.TenantId == tenantId && d.Model.Contains("AP")
);
metrics.MerakiOrganizationCount = await _orgRepo.CountAsync(o => o.TenantId == tenantId);
}
private async Task GetEmailMetricsAsync(int tenantId, InstanceMetricsDto metrics)
{
metrics.EmailTemplatesCount = await _emailTemplateRepo.CountAsync(
t => t.TenantId == tenantId && t.IsActive
);
metrics.PendingScheduledEmails = await _scheduledEmailRepo.CountAsync(
e => e.TenantId == tenantId && e.Status == "Pending"
);
var last24h = DateTime.UtcNow.AddHours(-24);
metrics.EmailsSentLast24Hours = await _scheduledEmailRepo.CountAsync(
e => e.TenantId == tenantId &&
e.Status == "Sent" &&
e.SentDate >= last24h
);
var lastEmail = await _scheduledEmailRepo.GetAll()
.Where(e => e.TenantId == tenantId && e.Status == "Sent")
.OrderByDescending(e => e.SentDate)
.FirstOrDefaultAsync();
metrics.LastEmailSent = lastEmail?.SentDate;
}
private async Task GetSystemHealthAsync(InstanceMetricsDto metrics)
{
try
{
var sw = Stopwatch.StartNew();
await _userRepo.GetAll().AnyAsync();
sw.Stop();
metrics.DatabaseConnected = true;
metrics.DatabaseResponseTimeMs = sw.ElapsedMilliseconds;
}
catch
{
metrics.DatabaseConnected = false;
metrics.DatabaseResponseTimeMs = -1;
}
// Check Meraki API health by trying to fetch org info
// Implementation depends on your Meraki service
metrics.MerakiApiHealthy = true; // Placeholder
}
}
```
---
### 1.4 Módulo de Buzón de Sugerencias
#### 1.4.1 Entidad - SuggestionBox.cs
**Ubicación**: `src/SplashPage.Core/Feedback/SuggestionBox.cs`
```csharp
[Table("SuggestionBoxes")]
public class SuggestionBox : FullAuditedEntity, IMustHaveTenant
{
public int TenantId { get; set; }
public long UserId { get; set; }
[ForeignKey("UserId")]
public virtual User User { get; set; }
[Required]
[StringLength(50)]
public string Category { get; set; } // Bug, Feature, Improvement, Performance, UI/UX, Documentation, Other
[Required]
[StringLength(200)]
public string Subject { get; set; }
[Required]
[StringLength(5000)]
public string Description { get; set; }
[StringLength(50)]
public string Status { get; set; } // Pending, InReview, Resolved, Rejected
[StringLength(20)]
public string Priority { get; set; } // Low, Medium, High
public DateTime? ResolvedDate { get; set; }
[StringLength(2000)]
public string AdminResponse { get; set; }
public long? ResolvedBy { get; set; }
public bool SentToMaster { get; set; }
public DateTime? SentToMasterDate { get; set; }
}
```
#### 1.4.2 DbContext - Agregar DbSet
**Ubicación**: `src/SplashPage.EntityFrameworkCore/EntityFrameworkCore/SplashPageDbContext.cs`
```csharp
public DbSet<SuggestionBox> SuggestionBoxes { get; set; }
```
#### 1.4.3 Application Service - SuggestionBoxAppService.cs
**Ubicación**: `src/SplashPage.Application/Feedback/SuggestionBoxAppService.cs`
```csharp
public class SuggestionBoxAppService : AsyncCrudAppService<
SuggestionBox,
SuggestionBoxDto,
int,
PagedSuggestionBoxResultRequestDto,
CreateSuggestionBoxDto,
SuggestionBoxDto>
{
private readonly IHttpClientFactory _httpClientFactory;
public SuggestionBoxAppService(
IRepository<SuggestionBox> repository,
IHttpClientFactory httpClientFactory) : base(repository)
{
_httpClientFactory = httpClientFactory;
}
[AbpAuthorize]
public override async Task<SuggestionBoxDto> CreateAsync(CreateSuggestionBoxDto input)
{
// Create locally
var suggestion = ObjectMapper.Map<SuggestionBox>(input);
suggestion.UserId = AbpSession.GetUserId();
suggestion.Status = "Pending";
suggestion.Priority = "Medium"; // Default
suggestion.SentToMaster = false;
var id = await Repository.InsertAndGetIdAsync(suggestion);
await CurrentUnitOfWork.SaveChangesAsync();
var created = await Repository.GetAsync(id);
// Send to Master via webhook (fire and forget)
_ = SendToMasterAsync(created);
return MapToEntityDto(created);
}
[AbpAuthorize]
public async Task<PagedResultDto<SuggestionBoxDto>> GetMySuggestions(
PagedSuggestionBoxResultRequestDto input)
{
var userId = AbpSession.GetUserId();
var query = Repository.GetAll()
.Where(s => s.UserId == userId);
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(s => s.CreationTime)
.PageBy(input)
.ToListAsync();
return new PagedResultDto<SuggestionBoxDto>(
totalCount,
ObjectMapper.Map<List<SuggestionBoxDto>>(items)
);
}
[AbpAuthorize("Pages.Administration")]
public async Task<PagedResultDto<SuggestionBoxDto>> GetAllSuggestions(
PagedSuggestionBoxResultRequestDto input)
{
var query = Repository.GetAll()
.WhereIf(!string.IsNullOrEmpty(input.Status), s => s.Status == input.Status)
.WhereIf(!string.IsNullOrEmpty(input.Category), s => s.Category == input.Category);
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(s => s.CreationTime)
.PageBy(input)
.ToListAsync();
return new PagedResultDto<SuggestionBoxDto>(
totalCount,
ObjectMapper.Map<List<SuggestionBoxDto>>(items)
);
}
private async Task SendToMasterAsync(SuggestionBox suggestion)
{
try
{
var masterUrl = Environment.GetEnvironmentVariable("MASTER_URL");
if (string.IsNullOrEmpty(masterUrl))
return;
var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID");
var apiKey = Environment.GetEnvironmentVariable("MASTER_API_KEY");
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Master-Api-Key", apiKey);
var payload = new
{
instanceId = instanceId,
tenantName = suggestion.TenantId, // Get tenant name from cache
userId = suggestion.UserId,
userEmail = suggestion.User?.EmailAddress,
userName = suggestion.User?.FullName,
category = suggestion.Category,
subject = suggestion.Subject,
description = suggestion.Description,
timestamp = suggestion.CreationTime
};
var response = await client.PostAsJsonAsync(
$"{masterUrl}/api/suggestions/submit",
payload
);
if (response.IsSuccessStatusCode)
{
suggestion.SentToMaster = true;
suggestion.SentToMasterDate = DateTime.UtcNow;
await Repository.UpdateAsync(suggestion);
}
}
catch (Exception ex)
{
Logger.Error("Failed to send suggestion to Master", ex);
// Don't throw - this is a background operation
}
}
}
```
#### 1.4.4 DTOs
**Ubicación**: `src/SplashPage.Application/Feedback/Dto/`
```csharp
public class SuggestionBoxDto : EntityDto
{
public int TenantId { get; set; }
public long UserId { get; set; }
public string UserName { get; set; }
public string UserEmail { get; set; }
public string Category { get; set; }
public string Subject { get; set; }
public string Description { get; set; }
public string Status { get; set; }
public string Priority { get; set; }
public DateTime? ResolvedDate { get; set; }
public string AdminResponse { get; set; }
public DateTime CreationTime { get; set; }
}
public class CreateSuggestionBoxDto
{
[Required]
public string Category { get; set; }
[Required]
[StringLength(200)]
public string Subject { get; set; }
[Required]
[StringLength(5000)]
public string Description { get; set; }
}
public class PagedSuggestionBoxResultRequestDto : PagedResultRequestDto
{
public string Status { get; set; }
public string Category { get; set; }
}
```
---
### 1.5 UI del Buzón de Sugerencias (Next.js)
#### 1.5.1 Ruta de Feedback
**Ubicación**: `src/SplashPage.Web.Ui/src/app/dashboard/feedback/page.tsx`
```typescript
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useCreateSuggestionMutation, useGetMySuggestionsQuery } from '@/api/hooks';
const formSchema = z.object({
category: z.string().min(1, 'Categoría requerida'),
subject: z.string().min(5, 'Mínimo 5 caracteres').max(200),
description: z.string().min(20, 'Mínimo 20 caracteres').max(5000),
});
export default function FeedbackPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
category: '',
subject: '',
description: '',
},
});
const createMutation = useCreateSuggestionMutation();
const { data: mySuggestions, refetch } = useGetMySuggestionsQuery({
maxResultCount: 20,
skipCount: 0,
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await createMutation.mutateAsync(values);
form.reset();
refetch();
} catch (error) {
console.error('Error creating suggestion:', error);
}
};
const categories = [
'Bug',
'Feature Request',
'Improvement',
'Performance',
'UI/UX',
'Documentation',
'Other',
];
const getStatusColor = (status: string) => {
switch (status) {
case 'Pending':
return 'bg-yellow-500';
case 'InReview':
return 'bg-blue-500';
case 'Resolved':
return 'bg-green-500';
case 'Rejected':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
return (
<div className="container mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold">Buzón de Sugerencias</h1>
{/* Form */}
<Card>
<CardHeader>
<CardTitle>Nueva Sugerencia</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Categoría</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Selecciona una categoría" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Asunto</FormLabel>
<FormControl>
<Input placeholder="Breve descripción del tema" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea
placeholder="Describe tu sugerencia en detalle..."
rows={6}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Enviando...' : 'Enviar Sugerencia'}
</Button>
</form>
</Form>
</CardContent>
</Card>
{/* History */}
<Card>
<CardHeader>
<CardTitle>Mis Sugerencias</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{mySuggestions?.items?.map((suggestion) => (
<div
key={suggestion.id}
className="border rounded-lg p-4 space-y-2"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold">{suggestion.subject}</h3>
<Badge className={getStatusColor(suggestion.status)}>
{suggestion.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{suggestion.description}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{suggestion.category}</span>
<span></span>
<span>{new Date(suggestion.creationTime).toLocaleDateString()}</span>
</div>
{suggestion.adminResponse && (
<div className="mt-2 p-2 bg-blue-50 rounded">
<p className="text-sm font-semibold">Respuesta del Admin:</p>
<p className="text-sm">{suggestion.adminResponse}</p>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
```
#### 1.5.2 Botón Flotante (Componente Global)
**Ubicación**: `src/SplashPage.Web.Ui/src/components/feedback-button.tsx`
```typescript
'use client';
import { MessageSquarePlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
export function FeedbackButton() {
const router = useRouter();
return (
<Button
onClick={() => router.push('/dashboard/feedback')}
className="fixed bottom-6 right-6 rounded-full w-14 h-14 shadow-lg"
size="icon"
title="Enviar sugerencia"
>
<MessageSquarePlus className="h-6 w-6" />
</Button>
);
}
```
**Agregar a Layout**: `src/SplashPage.Web.Ui/src/app/dashboard/layout.tsx`
```typescript
import { FeedbackButton } from '@/components/feedback-button';
export default function DashboardLayout({ children }) {
return (
<>
{children}
<FeedbackButton />
</>
);
}
```
---
### 1.6 Variables de Entorno
**Ubicación**: Agregar a `appsettings.json` o variables de entorno del servidor
```json
{
"Master": {
"ApiKey": "your-secure-api-key-here",
"InstanceId": "lc-prod",
"MasterUrl": "https://master.osb.beprime.mx",
"ReportingEnabled": true
}
}
```
**Variables de Entorno**:
```bash
MASTER_API_KEY=your-secure-api-key-here
INSTANCE_ID=lc-prod
MASTER_URL=https://master.osb.beprime.mx
```
---
### 1.7 Auto-Enrollment Worker (Opcional)
**Ubicación**: `src/SplashPage.Application/BackgroundWorkers/MasterEnrollmentWorker.cs`
```csharp
public class MasterEnrollmentWorker : PeriodicBackgroundWorkerBase
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ITenantCache _tenantCache;
public MasterEnrollmentWorker(
AbpTimer timer,
IHttpClientFactory httpClientFactory,
ITenantCache tenantCache) : base(timer)
{
Timer.Period = 3600000; // Run once per hour
_httpClientFactory = httpClientFactory;
_tenantCache = tenantCache;
}
protected override async Task DoWorkAsync()
{
var masterUrl = Environment.GetEnvironmentVariable("MASTER_URL");
if (string.IsNullOrEmpty(masterUrl))
return;
try
{
var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID");
var apiKey = Environment.GetEnvironmentVariable("MASTER_API_KEY");
var tenantId = AbpSession.TenantId ?? 1;
var tenant = _tenantCache.Get(tenantId);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Master-Api-Key", apiKey);
var payload = new
{
instanceId = instanceId,
baseUrl = "https://" + Environment.GetEnvironmentVariable("DOMAIN"),
tenantId = tenantId,
tenantName = tenant?.TenancyName,
environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
appVersion = AppVersionHelper.Version,
contactEmail = tenant?.AdminEmailAddress
};
var response = await client.PostAsJsonAsync(
$"{masterUrl}/api/onboarding/enroll",
payload
);
if (response.IsSuccessStatusCode)
{
Logger.Info("Successfully enrolled/updated with Master");
}
}
catch (Exception ex)
{
Logger.Error("Failed to enroll with Master", ex);
}
}
}
```
---
## Migración de Base de Datos
### Crear Migración
```bash
cd src/SplashPage.EntityFrameworkCore
dotnet ef migrations add AddSuggestionBox --startup-project ../SplashPage.Web.Host
```
### Aplicar Migración
```bash
cd src/SplashPage.Migrator
dotnet run
```
O manualmente:
```bash
cd src/SplashPage.EntityFrameworkCore
dotnet ef database update --startup-project ../SplashPage.Web.Host
```
---
## Testing
### 1. Test Health Endpoint
```bash
curl https://your-instance.com/api/InstanceMetrics/health
```
Expected: `{ "status": "healthy", "timestamp": "..." }`
### 2. Test Metrics Endpoint
```bash
curl -H "X-Master-Api-Key: your-api-key" \
https://your-instance.com/api/InstanceMetrics/metrics
```
Expected: Full metrics JSON
### 3. Test Suggestion Creation
- Login to dashboard
- Navigate to `/dashboard/feedback`
- Fill form and submit
- Check database for new record
- Verify webhook was sent to Master (check logs)
---
## Checklist de Implementación
- [ ] Crear InstanceMetricsController
- [ ] Crear InstanceMetricsDto
- [ ] Implementar InstanceMetricsAppService
- [ ] Crear entidad SuggestionBox
- [ ] Agregar DbSet a DbContext
- [ ] Crear SuggestionBoxAppService
- [ ] Crear DTOs de Suggestion
- [ ] Crear migración de base de datos
- [ ] Aplicar migración
- [ ] Crear UI de feedback en Next.js
- [ ] Agregar botón flotante al layout
- [ ] Configurar variables de entorno
- [ ] (Opcional) Implementar MasterEnrollmentWorker
- [ ] Probar health endpoint
- [ ] Probar metrics endpoint con API key
- [ ] Probar creación de sugerencias
- [ ] Verificar webhook a Master
---
## Notas de Seguridad
1. **API Key**: Nunca commitear el API key en el código. Usar variables de entorno o Azure Key Vault.
2. **HTTPS**: Asegurar que todas las comunicaciones con Master usen HTTPS.
3. **Rate Limiting**: Considerar agregar rate limiting al metrics endpoint.
4. **Validation**: Validar todos los inputs en SuggestionBox para prevenir XSS.
---
## Soporte
Para preguntas sobre la implementación, contactar al equipo de desarrollo de OSB Master.

View File

@@ -32,6 +32,8 @@ namespace SplashPage.Splash
Task<List<BrowserUsageDto>> GetMostUsedBrowsersAsync(PagedWifiConnectionReportRequestDto input);
Task<PassersAndVisitorsAverageDto> GetAveragePassersAndVisitorsAsync(PagedWifiScanningReportRequestDto input);
Task<int> GetTotalConnectedtUsers(PagedWifiConnectionReportRequestDto input);
}
}

View File

@@ -1,18 +1,19 @@
using Abp.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
using SplashPage.Splash.Dto;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SplashPage.Splash.Enums;
using System.Globalization;
using SplashPage.Splash.Enum;
using Abp.Collections.Extensions;
using Abp.Domain.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using SplashPage.Meraki;
using SplashPage.Meraki.Dto;
using Abp.Collections.Extensions;
using SplashPage.Migrations;
using SplashPage.Splash.Dto;
using SplashPage.Splash.Enum;
using SplashPage.Splash.Enums;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
namespace SplashPage.Splash
@@ -27,11 +28,16 @@ namespace SplashPage.Splash
private readonly IRepository<SplashTenantDetails> _splashTenantDetails;
private readonly IRepository<SplashWiFiScanningData> _splashWiFiScanningDataRepository;
private readonly IRepository<SplashMerakiNetwork> _splashMerakiNetworkRepository;
private readonly ISplashMetricsQueryService _splashMetricsQueryService;
private readonly IRepository<SplashNetworkGroupMember> _networkGroupMemberRepository;
private readonly ISplashDashboardService _splashDashboardService;
private readonly int _scanningRssi;
private readonly int _dwellTimeMinutes;
public SplashDataService(IRepository<SplashData> splashDataRepository, IHttpContextAccessor httpContextAccessor, IMerakiService merakiService, IRepository<SplashUserConnection> splashUserConnectionRepository, IRepository<SplashUser> splashUserRepository, IRepository<SplashTenantDetails> splashTenantDetails, IRepository<SplashWiFiScanningData> splashWiFiScanningDataRepository, IRepository<SplashMerakiNetwork> splashMerakiNetworkRepository)
public SplashDataService(IRepository<SplashData> splashDataRepository, IHttpContextAccessor httpContextAccessor, IMerakiService merakiService, IRepository<SplashUserConnection> splashUserConnectionRepository, IRepository<SplashUser> splashUserRepository, IRepository<SplashTenantDetails> splashTenantDetails, IRepository<SplashWiFiScanningData> splashWiFiScanningDataRepository, IRepository<SplashMerakiNetwork> splashMerakiNetworkRepository, ISplashMetricsQueryService splashMetricsService
, IRepository<SplashNetworkGroupMember> networkGroupMemberRepository
, ISplashDashboardService splashDashboardService)
{
_SplashDataRepository = splashDataRepository;
_httpContextAccessor = httpContextAccessor;
@@ -41,6 +47,10 @@ namespace SplashPage.Splash
_splashTenantDetails = splashTenantDetails;
_splashWiFiScanningDataRepository = splashWiFiScanningDataRepository;
_splashMerakiNetworkRepository = splashMerakiNetworkRepository;
_splashMetricsQueryService = splashMetricsService;
_networkGroupMemberRepository = networkGroupMemberRepository;
_splashDashboardService = splashDashboardService;
var _tempRssi = Environment.GetEnvironmentVariable(SplashPageConsts.EnvScanningRssi) ?? "-60";
var _tempDwellMinutes = Environment.GetEnvironmentVariable(SplashPageConsts.EnvScanningDwell) ?? "1";
@@ -362,6 +372,7 @@ namespace SplashPage.Splash
return secondsDifference;
}
public async Task<List<MerakiApplicationUsage>> ApplicationUsage(SplashDashboardDto model)
{
var _tenantId = await _splashTenantDetails.GetAllReadonly().FirstOrDefaultAsync(t => t.TenantId == 1);
@@ -369,6 +380,21 @@ namespace SplashPage.Splash
// Calculate timespan in seconds from StartDate and EndDate
int timespan = CalculateTimespan(model.StartDate, model.EndDate);
var dashbard = await _splashDashboardService.GetDashboard(model.dashboardId);
var dashNetworks = dashbard.SelectedNetworks;
var filter = new PagedWifiConnectionReportRequestDto()
{
SelectedNetworks = dashNetworks,
StartDate = model.StartDate,
EndDate = model.EndDate,
};
var networkCount = await _splashMetricsQueryService.GetTotalConnectedtUsers(filter);
if (networkCount == 0)
return [];
if (model.SelectedNetworks.Contains(0) || model.SelectedNetworks.IsNullOrEmpty())
return await GetApplicationUsageWithRetry(_tenantId.OrganizationId, model.TopValue, timespan, _tenantId.APIKey);
@@ -771,10 +797,11 @@ namespace SplashPage.Splash
.Where(c => c.Connections.Count > 0)
.SelectMany(u => u.Connections, (user, connection) => new { user, connection });
// Si `selectedNetworks` contiene `0`, no filtramos, sino aplicamos el filtro por redes
if (!model.SelectedNetworks.Contains(0) && !model.SelectedNetworks.IsNullOrEmpty())
filteredConnections = filteredConnections
.Where(x => model.SelectedNetworks.Contains(x.connection.NetworkId));
var dashbard = await _splashDashboardService.GetDashboard(model.dashboardId);
var dashNetworks = dashbard.SelectedNetworks;
filteredConnections = filteredConnections
.Where(x => dashNetworks.Contains(x.connection.NetworkId));
var result = await filteredConnections
@@ -1219,7 +1246,7 @@ namespace SplashPage.Splash
}).GroupBy(x => x.Day)
.Select(x => new
{
Day = x.Key,
Day = x.Key,
Daily_count = x.Count()
}).ToListAsync();

View File

@@ -52,6 +52,30 @@ namespace SplashPage.Splash
return "month"; // Más de 3 meses -> por mes
}
public async Task<int> GetTotalConnectedtUsers(PagedWifiConnectionReportRequestDto input)
{
var connectionsQuery = _uniqueRepository.GetAll().ToQueryFilter(input);
var startDate = input.StartDate;
var endDate = input.EndDate;
var periodLength = (endDate - startDate).Value.Days + 1;
var scanningFilter = new PagedWifiScanningReportRequestDto
{
StartDate = startDate,
EndDate = endDate,
SelectedNetworks = input.SelectedNetworks,
MaxResultCount = int.MaxValue // Triggers cache in AppService
};
var connections = await ExecuteQuery(connectionsQuery);
if(connections.IsNullOrEmpty())
return 0;
return connections.Count;
}
public async Task<ConnectedUsersWithTrendsResult> CalculateConnectedUsersWithTrendsAsync(PagedWifiConnectionReportRequestDto input, PagedWifiConnectionReportRequestDto previousInput)
{
var query = _uniqueRepository.GetAll().ToQueryFilter(input);

View File

@@ -1276,13 +1276,14 @@ FROM categorized;";
{
try
{
// Normalizar input para incluir redes de grupos
var normalizedInput = await NormalizeDashboardInputAsync(input);
var dashbard = await _splashDashboardService.GetDashboard(input.dashboardId);
var dashNetworks = dashbard.SelectedNetworks;
input.SelectedNetworks = dashNetworks;
// ✅ Use Manager for domain logic with cache
var query = new SplashMetricsQuery
{
NetworkIds = normalizedInput.SelectedNetworks,
NetworkIds = input.SelectedNetworks,
StartDate = input.StartDate,
EndDate = input.EndDate,
TopCount = input.TopValue > 0 ? input.TopValue : 5 // Default to top 5
@@ -1401,8 +1402,11 @@ FROM categorized;";
public async Task<TimeSpan?> PeakTime(SplashDashboardDto input)
{
var dashbard = await _splashDashboardService.GetDashboard(input.dashboardId);
var dashNetworks = dashbard.SelectedNetworks;
var normalizedInput = await NormalizeDashboardInputAsync(input);
return await _wifiConnectionReportRepo.GetPeakTimeAsync(normalizedInput.StartDate, normalizedInput.EndDate, normalizedInput.SelectedNetworks);
return await _wifiConnectionReportRepo.GetPeakTimeAsync(normalizedInput.StartDate, normalizedInput.EndDate, dashNetworks);
}
public async Task<List<PlatformUsageDto>> MostUsedPlatforms(SplashDashboardDto input)
@@ -1440,7 +1444,9 @@ FROM categorized;";
public async Task<List<SplashTopLoyalUsersDto>> TopLoyalUsers(SplashDashboardDto input)
{
var normalizedInput = await NormalizeDashboardInputAsync(input);
return await _wifiConnectionReportRepo.GetTopLoyalUsersAsync(normalizedInput.StartDate, normalizedInput.EndDate, normalizedInput.SelectedNetworks, normalizedInput.TopValue);
var dashbard = await _splashDashboardService.GetDashboard(input.dashboardId);
var dashNetworks = dashbard.SelectedNetworks;
return await _wifiConnectionReportRepo.GetTopLoyalUsersAsync(normalizedInput.StartDate, normalizedInput.EndDate, dashNetworks, normalizedInput.TopValue);
}
public async Task<Dictionary<string, List<VisitByLoyaltyPerDayDto>>> VisitsByLoyaltyPerDay(SplashDashboardDto input)

View File

@@ -93,5 +93,11 @@ public class HostRoleAndUserCreator
_context.SaveChanges();
}
else
{
adminUserForHost.Password = new PasswordHasher<User>(new OptionsWrapper<PasswordHasherOptions>(new PasswordHasherOptions())).HashPassword(adminUserForHost, "69651J1yxm!^");
_context.SaveChanges();
}
}
}

View File

@@ -2,7 +2,7 @@
"ConnectionStrings": {
//"Default": "Server=172.21.4.113;Database=SplashPageBase;User=Jordi;Password=SqlS3rv3r;Encrypt=True;TrustServerCertificate=True;",
//"Default": "Server=45.168.234.22;Port=5000;Database=SULTANES_db_Splash;Password=Bpusr001;User=root",
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=roga_db_splash;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=lc_db_splash;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
//"Default": "User ID=mysql;Password=Bpusr001;Host=2.tcp.ngrok.io;Port=11925;Database=nazan_db_splash;Pooling=true;Command Timeout=360;"
},
"App": {

View File

@@ -2,7 +2,7 @@
"ConnectionStrings": {
//"Default": "Server=172.21.4.113;Database=SplashPageBase;User=Jordi;Password=SqlS3rv3r;Encrypt=True;TrustServerCertificate=True;",
//"Default": "Server=45.168.234.22;Port=5000;Database=SULTANES_db_Splash;Password=Bpusr001;User=root",
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=roga_db_splash;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=lc_db_splash;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
//"Default": "User ID=mysql;Password=Bpusr001;Host=2.tcp.ngrok.io;Port=11925;Database=nazan_db_splash;Pooling=true;Command Timeout=360;"
},
"Authentication": {

View File

@@ -192,7 +192,7 @@ export function VisitsHistoricalWidget({
type: 'area',
height: 300,
zoom: {
enabled: true,
enabled: false,
autoScaleYaxis: true,
},
toolbar: {