diff --git a/MP.Data/DbModels/Utils/StatsAggregatedModel.cs b/MP.Data/DbModels/Utils/StatsAggregatedModel.cs
new file mode 100644
index 00000000..4221288b
--- /dev/null
+++ b/MP.Data/DbModels/Utils/StatsAggregatedModel.cs
@@ -0,0 +1,47 @@
+//
+// This is here so CodeMaid doesn't reorganize this document
+//
+namespace MP.Data.DbModels.Utils
+{
+ ///
+ /// Classe statistiche impiego orario complessive
+ ///
+ [Table("stats_aggr")]
+ public class StatsAggregatedModel
+ {
+ [Key]
+ public int Id { get; set; }
+ ///
+ /// DataOra riferimento
+ ///
+ public DateTime Hour { get; set; }
+ ///
+ /// Num richieste complete/concluse (con ritorno)
+ ///
+ public long RequestCount { get; set; } = 0;
+ ///
+ /// Durata media esecuzione
+ ///
+ public double AvgDuration { get; set; } = 0;
+ ///
+ /// Durata massima esecuzione
+ ///
+ public double MaxDuration { get; set; } = 0;
+ ///
+ /// Durata 5 perc esecuzione
+ ///
+ public double Perc05Duration { get; set; } = 0;
+ ///
+ /// Durata 95 perc esecuzione
+ ///
+ public double Perc95Duration { get; set; } = 0;
+ ///
+ /// Durata massima esecuzione
+ ///
+ public double MaxDuration { get; set; } = 0;
+ ///
+ /// numero richieste senza risposta (num RUID avviati - num RUID chiusi)
+ ///
+ public long NoReply { get; set; } = 0;
+ }
+}
diff --git a/MP.Data/DbModels/Utils/StatsDetailModel.cs b/MP.Data/DbModels/Utils/StatsDetailModel.cs
new file mode 100644
index 00000000..0aa9b4a1
--- /dev/null
+++ b/MP.Data/DbModels/Utils/StatsDetailModel.cs
@@ -0,0 +1,51 @@
+//
+// This is here so CodeMaid doesn't reorganize this document
+//
+namespace MP.Data.DbModels.Utils
+{
+ ///
+ /// Classe statistiche impiego (orarie) di dettaglio (per envir/tipo)
+ ///
+ [Table("stats_detail")]
+ public class StatsDetailModel
+ {
+ [Key]
+ public int Id { get; set; }
+ ///
+ /// Ambiente
+ ///
+ public string Environment { get; set; } = "";
+ ///
+ /// Codice mode/submode
+ ///
+ public string Type { get; set; } = "";
+ ///
+ /// DataOra riferimento
+ ///
+ public DateTime Hour { get; set; }
+ ///
+ /// Num richieste complete/concluse (con ritorno)
+ ///
+ public long RequestCount { get; set; } = 0;
+ ///
+ /// Durata media esecuzione
+ ///
+ public double AvgDuration { get; set; } = 0;
+ ///
+ /// Durata 5 perc esecuzione
+ ///
+ public double Perc05Duration { get; set; } = 0;
+ ///
+ /// Durata 95 perc esecuzione
+ ///
+ public double Perc95Duration { get; set; } = 0;
+ ///
+ /// Durata massima esecuzione
+ ///
+ public double MaxDuration { get; set; } = 0;
+ ///
+ /// numero richieste senza risposta (num RUID avviati - num RUID chiusi)
+ ///
+ public long NoReply { get; set; } = 0;
+ }
+}
diff --git a/MP.Data/MoonPro_UtilsContext.cs b/MP.Data/MoonPro_UtilsContext.cs
new file mode 100644
index 00000000..b87aad6c
--- /dev/null
+++ b/MP.Data/MoonPro_UtilsContext.cs
@@ -0,0 +1,131 @@
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.Extensions.Configuration;
+using MP.Core.DTO;
+using MP.Data.DbModels;
+using MP.Data.DTO;
+using NLog;
+
+#nullable disable
+//
+// This is here so CodeMaid doesn't reorganize this document
+//
+namespace MP.Data
+{
+ public partial class MoonPro_UtilsContext : DbContext
+ {
+ #region Private Fields
+
+ private static NLog.Logger Log = LogManager.GetCurrentClassLogger();
+
+ private IConfiguration _configuration;
+
+ #endregion Private Fields
+
+ #region Public Constructors
+
+ ///
+ /// Indispensabile x prima generazione migrations EFCore
+ ///
+
+ [Obsolete("This constructor should never be used directly, and is only needed to generate entityframework stuff. Connection string can be adapted as pleased.")]
+ public MoonPro_UtilsContext()
+ {
+ }
+
+ public MoonPro_UtilsContext(IConfiguration configuration)
+ {
+ _configuration = configuration;
+ }
+
+ public MoonPro_UtilsContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ #endregion Public Constructors
+
+ #region Public Properties
+
+
+ public virtual DbSet DbSetStatsDet { get; set; }
+ public virtual DbSet DbSetStatsAggr { get; set; }
+
+ #endregion Public Properties
+
+ #region Private Methods
+
+ partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
+
+ #endregion Private Methods
+
+ #region Protected Methods
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ if (!optionsBuilder.IsConfigured)
+ {
+ string connString = _configuration.GetConnectionString("MP.Utils");
+ if (!string.IsNullOrEmpty(connString))
+ {
+ optionsBuilder.UseSqlServer(connString);
+ }
+ else
+ {
+ optionsBuilder.UseSqlServer("Server=SQL2016DEV;Database=MoonPro_Utils;Trusted_Connection=True;");
+ }
+ }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasAnnotation("Relational:Collation", "SQL_Latin1_General_CP1_CI_AS");
+
+ // indici tabelle stats
+ modelBuilder.Entity()
+ .HasIndex(x => new { x.Hour, x.Environment, x.Type })
+ .HasDatabaseName("idx_statsdet_hour_env_type")
+ .IsUnique();
+ modelBuilder.Entity()
+ .HasIndex(x => new { x.Hour })
+ .HasDatabaseName("idx_statsaggr_hour")
+ .IsUnique();
+
+ // modelBuilder.Entity(entity =>
+ // {
+ // entity.HasKey(e => e.CodArticolo);
+
+ // entity.ToView("AnagArticoli");
+
+ // entity.Property(e => e.CodArticolo)
+ // .IsRequired()
+ // .HasMaxLength(50);
+
+ // entity.Property(e => e.DescArticolo)
+ // .IsRequired()
+ // .HasMaxLength(250);
+
+ // entity.Property(e => e.Disegno)
+ // .IsRequired()
+ // .HasMaxLength(50);
+
+ // entity.Property(e => e.Tipo)
+ // .IsRequired()
+ // .HasMaxLength(50);
+
+ // entity.Property(e => e.Azienda)
+ // .IsRequired()
+ // .HasMaxLength(50);
+ // });
+
+ // modelBuilder.Entity(entity =>
+ // {
+ // entity.HasKey(e => new { e.IdxMacchina, e.CodFlux });
+ // });
+
+ OnModelCreatingPartial(modelBuilder);
+ }
+
+ #endregion Protected Methods
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Repository/Utils/IStatsAggrRepository.cs b/MP.Data/Repository/Utils/IStatsAggrRepository.cs
new file mode 100644
index 00000000..5f2bf909
--- /dev/null
+++ b/MP.Data/Repository/Utils/IStatsAggrRepository.cs
@@ -0,0 +1,32 @@
+using EgwCoreLib.Utils;
+
+namespace MP.Data.Repository.Utils
+{
+ ///
+ /// Interfaccia per le statistiche aggregate.
+ ///
+ public interface IStatsAggrRepository
+ {
+ ///
+ /// Recupera l'elenco delle statistiche aggregate per un periodo specifico.
+ ///
+ /// La data di inizio del periodo.
+ /// La data di fine del periodo.
+ /// L'elenco delle statistiche aggregate ordinate cronologicamente.
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd);
+
+ ///
+ /// Recupera l'intervallo temporale disponibile nel database per le statistiche aggregate.
+ ///
+ /// L'intervallo di date (minima e massima ora presente).
+ Task GetRangeAsync();
+
+ ///
+ /// Inserisce o aggiorna in blocco le statistiche aggregate nel database.
+ ///
+ /// L'elenco dei record da inserire.
+ /// Se true, elimina preventivamente i record nel periodo richiesto.
+ /// Il numero di record inseriti.
+ Task UpsertManyAsync(List listRecords, bool removeOld);
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Repository/Utils/IStatsDetailRepository.cs b/MP.Data/Repository/Utils/IStatsDetailRepository.cs
new file mode 100644
index 00000000..3db2182c
--- /dev/null
+++ b/MP.Data/Repository/Utils/IStatsDetailRepository.cs
@@ -0,0 +1,36 @@
+using EgwCoreLib.Utils;
+
+namespace MP.Data.Repository.Utils
+{
+ ///
+ /// Interfaccia per le statistiche di dettaglio.
+ ///
+ public interface IStatsDetailRepository
+ {
+ ///
+ /// Recupera l'elenco delle statistiche di dettaglio per un periodo specifico, con filtri opzionali.
+ ///
+ /// La data di inizio del periodo.
+ /// La data di fine del periodo.
+ /// Filtro opzionale per l'ambiente (es. "DEV", "PROD").
+ /// Filtro opzionale per il tipo di statistica.
+ /// L'elenco delle statistiche di dettaglio ordinate cronologicamente.
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd, string sEnvir = "", string sType = "");
+
+ ///
+ /// Recupera l'intervallo temporale disponibile nel database per le statistiche di dettaglio.
+ ///
+ /// Filtro opzionale per l'ambiente.
+ /// Filtro opzionale per il tipo.
+ /// L'intervallo di date (minima e massima ora presente).
+ Task GetRangeAsync(string sEnvir, string sType);
+
+ ///
+ /// Inserisce o aggiorna in blocco le statistiche di dettaglio nel database.
+ ///
+ /// L'elenco dei record da inserire.
+ /// Se true, elimina preventivamente i record nel periodo richiesto.
+ /// Il numero di record inseriti.
+ Task UpsertManyAsync(List listRecords, bool removeOld);
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Repository/Utils/StatsAggrRepository.cs b/MP.Data/Repository/Utils/StatsAggrRepository.cs
new file mode 100644
index 00000000..2f9e6657
--- /dev/null
+++ b/MP.Data/Repository/Utils/StatsAggrRepository.cs
@@ -0,0 +1,93 @@
+using EgwCoreLib.Utils;
+
+namespace MP.Data.Repository.Utils
+{
+ public class StatsAggrRepository : BaseRepository, IStatsAggrRepository
+ {
+ #region Public Constructors
+
+ public StatsAggrRepository(IDbContextFactory ctxFactory) : base(ctxFactory)
+ {
+ }
+
+ #endregion Public Constructors
+
+ #region Public Methods
+
+ ///
+ public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd)
+ {
+ await using var dbCtx = await CreateContextAsync();
+ return await dbCtx
+ .DbSetStatsAggr
+ .Where(x => x.Hour >= dtStart && x.Hour <= dtEnd)
+ .AsNoTracking()
+ .OrderBy(x => x.Hour)
+ .ToListAsync();
+ }
+
+ ///
+ public async Task GetRangeAsync()
+ {
+ await using var dbCtx = await CreateContextAsync();
+ DtUtils.Periodo answ = new DtUtils.Periodo(DtUtils.PeriodSet.Today);
+ var query = dbCtx.DbSetStatsAggr.AsQueryable();
+ var minHour = await query.MinAsync(x => x.Hour);
+ var maxHour = await query.MaxAsync(x => x.Hour);
+ answ.Inizio = minHour;
+ answ.Fine = maxHour;
+ // ritorno!
+ return answ;
+ }
+
+ ///
+ public async Task UpsertManyAsync(List listRecords, bool removeOld)
+ {
+ int answ = 0;
+ await using var dbCtx = await CreateContextAsync();
+ await using var tx = await dbCtx.Database.BeginTransactionAsync();
+ try
+ {
+ // in primis se richiesto calcolo range periodo e svuoto...
+ if (removeOld)
+ {
+ var firstRec = listRecords.OrderBy(x => x.Hour).FirstOrDefault();
+ var lastRec = listRecords.OrderByDescending(x => x.Hour).FirstOrDefault();
+
+ if (firstRec != null && lastRec != null)
+ {
+ DateTime startDate = firstRec.Hour;
+ DateTime endDate = lastRec.Hour;
+ // uso direttamente ExecuteDelete
+ await dbCtx
+ .DbSetStatsAggr
+ .Where(x => x.Hour >= startDate && x.Hour <= endDate)
+ .ExecuteDeleteAsync();
+ }
+ }
+
+ // ora preparo inserimento massivo
+ await dbCtx
+ .DbSetStatsAggr
+ .AddRangeAsync(listRecords);
+
+ // salvo!
+ answ = await dbCtx.SaveChangesAsync();
+
+ // commit transazione
+ await tx.CommitAsync();
+
+ // libero memoria del changeTracker
+ dbCtx.ChangeTracker.Clear();
+ return answ;
+ }
+ catch
+ {
+ await tx.RollbackAsync();
+ throw;
+ }
+ }
+
+ #endregion Public Methods
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Repository/Utils/StatsDetailRepository.cs b/MP.Data/Repository/Utils/StatsDetailRepository.cs
new file mode 100644
index 00000000..4b09d801
--- /dev/null
+++ b/MP.Data/Repository/Utils/StatsDetailRepository.cs
@@ -0,0 +1,113 @@
+using EgwCoreLib.Utils;
+
+namespace MP.Data.Repository.Utils
+{
+ public class StatsDetailRepository : BaseRepository, IStatsDetailRepository
+ {
+ #region Public Constructors
+
+ public StatsDetailRepository(IDbContextFactory ctxFactory) : base(ctxFactory)
+ {
+ }
+
+ #endregion Public Constructors
+
+ #region Internal Methods
+
+ ///
+ public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd, string sEnvir = "", string sType = "")
+ {
+ await using var dbCtx = await CreateContextAsync();
+ List answ = new List();
+
+ // recupero ed ordino per data-ora
+ var query = dbCtx.DbSetStatsDet
+ .Where(x => x.Hour >= dtStart && x.Hour <= dtEnd);
+
+ if (!string.IsNullOrEmpty(sEnvir))
+ query = query.Where(x => x.Environment == sEnvir);
+
+ if (!string.IsNullOrEmpty(sType))
+ query = query.Where(x => x.Type == sType);
+
+ answ = await query
+ .AsNoTracking()
+ .OrderBy(x => x.Hour)
+ .ThenBy(x => x.Environment)
+ .ThenBy(x => x.Type)
+ .ToListAsync();
+
+ return answ;
+ }
+
+ ///
+ public async Task GetRangeAsync(string sEnvir, string sType)
+ {
+ await using var dbCtx = await CreateContextAsync();
+ DtUtils.Periodo answ = new DtUtils.Periodo(DtUtils.PeriodSet.Today);
+
+ var query = dbCtx.DbSetStatsDet.AsQueryable();
+
+ if (!string.IsNullOrEmpty(sEnvir))
+ query = query.Where(x => x.Environment == sEnvir);
+
+ if (!string.IsNullOrEmpty(sType))
+ query = query.Where(x => x.Type == sType);
+
+ var minHour = await query.MinAsync(x => x.Hour);
+ var maxHour = await query.MaxAsync(x => x.Hour);
+ answ.Inizio = minHour;
+ answ.Fine = maxHour;
+ return answ;
+ }
+
+ ///
+ public async Task UpsertManyAsync(List listRecords, bool removeOld)
+ {
+ int answ = 0;
+ await using var dbCtx = await CreateContextAsync();
+ await using var tx = await dbCtx.Database.BeginTransactionAsync();
+ try
+ {
+ // in primis se richiesto calcolo range periodo e svuoto...
+ if (removeOld)
+ {
+ var firstRec = listRecords.OrderBy(x => x.Hour).FirstOrDefault();
+ var lastRec = listRecords.OrderByDescending(x => x.Hour).FirstOrDefault();
+
+ if (firstRec != null && lastRec != null)
+ {
+ DateTime startDate = firstRec.Hour;
+ DateTime endDate = lastRec.Hour;
+ // uso direttamente ExecuteDelete
+ await dbCtx
+ .DbSetStatsDet
+ .Where(x => x.Hour >= startDate && x.Hour <= endDate)
+ .ExecuteDeleteAsync();
+ }
+ }
+
+ // ora preparo inserimento massivo
+ await dbCtx
+ .DbSetStatsDet
+ .AddRangeAsync(listRecords);
+
+ // salvo!
+ answ = await dbCtx.SaveChangesAsync();
+ // commit transazione
+ await tx.CommitAsync();
+
+ // libero memoria del changeTracker
+ dbCtx.ChangeTracker.Clear();
+ return answ;
+ }
+ catch
+ {
+ await tx.RollbackAsync();
+ throw;
+ }
+ }
+
+ #endregion Internal Methods
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Services/Utils/IStatsAggrService.cs b/MP.Data/Services/Utils/IStatsAggrService.cs
new file mode 100644
index 00000000..d38a0403
--- /dev/null
+++ b/MP.Data/Services/Utils/IStatsAggrService.cs
@@ -0,0 +1,31 @@
+namespace MP.Data.Services.Utils
+{
+ public interface IStatsAggrService
+ {
+ #region Public Methods
+
+ ///
+ /// Recupera l'elenco delle statistiche aggregate per un periodo specificato.
+ /// Utilizza la cache automaticamente.
+ ///
+ /// Data inizio periodo
+ /// Data fine periodo
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd);
+
+ ///
+ /// Recupera il range di periodi valido per le chiamate aggregate.
+ /// Utilizza la cache automaticamente.
+ ///
+ Task GetRangeAsync();
+
+ ///
+ /// Inserisce o aggiorna in batch le statistiche aggregate nel database.
+ /// Opzionalmente elimina i record precedenti nel periodo specificato.
+ ///
+ /// Elenco dei record da inserire/aggiornare
+ /// Se true elimina preventivamente i record nel periodo richiesto
+ Task UpsertManyAsync(List listRecords, bool removeOld);
+
+ #endregion Public Methods
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Services/Utils/IStatsDetailService.cs b/MP.Data/Services/Utils/IStatsDetailService.cs
new file mode 100644
index 00000000..1eb399da
--- /dev/null
+++ b/MP.Data/Services/Utils/IStatsDetailService.cs
@@ -0,0 +1,35 @@
+namespace MP.Data.Services.Utils
+{
+ public interface IStatsDetailService
+ {
+ #region Public Methods
+
+ ///
+ /// Recupera le statistiche di dettaglio per un periodo specificato, con filtri opzionali su ambiente e tipo.
+ /// Utilizza la cache automaticamente.
+ ///
+ /// Data inizio periodo
+ /// Data fine periodo
+ /// Filtro opzionale per ambiente (default: vuoto = tutti)
+ /// Filtro opzionale per tipo (default: vuoto = tutti)
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd, string sEnvir = "", string sType = "");
+
+ ///
+ /// Recupera il range di periodi valido per le chiamate di dettaglio.
+ /// Utilizza la cache automaticamente.
+ ///
+ /// Ambiente filtrato
+ /// Tipo filtrato
+ Task GetRangeAsync(string sEnvir, string sType);
+
+ ///
+ /// Inserisce o aggiorna in batch le statistiche di dettaglio nel database.
+ /// Opzionalmente elimina i record precedenti nel periodo specificato.
+ ///
+ /// Elenco dei record da inserire/aggiornare
+ /// Se true elimina preventivamente i record nel periodo richiesto
+ Task UpsertManyAsync(List listRecords, bool removeOld);
+
+ #endregion Public Methods
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Services/Utils/StatsAggrService.cs b/MP.Data/Services/Utils/StatsAggrService.cs
new file mode 100644
index 00000000..2d6ec1ff
--- /dev/null
+++ b/MP.Data/Services/Utils/StatsAggrService.cs
@@ -0,0 +1,74 @@
+namespace MP.Data.Services.Utils
+{
+ public class StatsAggrService : BaseServ, IStatsAggrService
+ {
+ #region Public Constructors
+
+ public StatsAggrService(
+ IConfiguration config,
+ IConnectionMultiplexer redis,
+ IStatsAggrRepository repo) : base(config, redis)
+ {
+ _className = "StatsAggr";
+ _repo = repo;
+ }
+
+ #endregion Public Constructors
+
+ #region Public Methods
+
+ ///
+ public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd)
+ {
+ return await TraceAsync($"{_className}.GetFilt", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:DT:{dtStart:yyyyMMdd}:{dtEnd:yyyyMMdd}",
+ async () => await _repo.GetFiltAsync(dtStart, dtEnd),
+ UltraLongCache
+ );
+ });
+ }
+
+ ///
+ public async Task GetRangeAsync()
+ {
+ return await TraceAsync($"{_className}.GetRange", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:Range",
+ async () => await _repo.GetRangeAsync(),
+ UltraFastCache
+ );
+ });
+ }
+
+ ///
+ public async Task UpsertManyAsync(List listRecords, bool removeOld)
+ {
+ return await TraceAsync($"{_className}.UpsertMany", async (activity) =>
+ {
+ string operation = "UpsertMany";
+ var success = await _repo.UpsertManyAsync(listRecords, removeOld);
+
+ activity?.SetTag("db.operation", operation);
+
+ if (success > 0)
+ {
+ await ClearCacheAsync($"{_redisBaseKey}:{_className}:*");
+ }
+
+ return success;
+ });
+ }
+
+ #endregion Public Methods
+
+ #region Private Fields
+
+ private readonly string _className;
+ private readonly IStatsAggrRepository _repo;
+
+ #endregion Private Fields
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Services/Utils/StatsDetailService.cs b/MP.Data/Services/Utils/StatsDetailService.cs
new file mode 100644
index 00000000..43a9d2fe
--- /dev/null
+++ b/MP.Data/Services/Utils/StatsDetailService.cs
@@ -0,0 +1,74 @@
+namespace MP.Data.Services.Utils
+{
+ public class StatsDetailService : BaseServ, IStatsDetailService
+ {
+ #region Public Constructors
+
+ public StatsDetailService(
+ IConfiguration config,
+ IConnectionMultiplexer redis,
+ IStatsDetailRepository repo) : base(config, redis)
+ {
+ _className = "StatsDetail";
+ _repo = repo;
+ }
+
+ #endregion Public Constructors
+
+ #region Public Methods
+
+ ///
+ public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd, string sEnvir = "", string sType = "")
+ {
+ return await TraceAsync($"{_className}.GetFilt", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:DT:{dtStart:yyyyMMdd}:{dtEnd:yyyyMMdd}",
+ async () => await _repo.GetFiltAsync(dtStart, dtEnd),
+ UltraLongCache
+ );
+ });
+ }
+
+ ///
+ public async Task GetRangeAsync(string sEnvir, string sType)
+ {
+ return await TraceAsync($"{_className}.GetRange", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:Range:{sEnvir}:{sType}",
+ async () => await _repo.GetRangeAsync(sEnvir, sType),
+ UltraFastCache
+ );
+ });
+ }
+
+ ///
+ public async Task UpsertManyAsync(List listRecords, bool removeOld)
+ {
+ return await TraceAsync($"{_className}.UpsertMany", async (activity) =>
+ {
+ string operation = "UpsertMany";
+ var success = await _repo.UpsertManyAsync(listRecords, removeOld);
+
+ activity?.SetTag("db.operation", operation);
+
+ if (success > 0)
+ {
+ await ClearCacheAsync($"{_redisBaseKey}:{_className}:*");
+ }
+
+ return success;
+ });
+ }
+
+ #endregion Public Methods
+
+ #region Private Fields
+
+ private readonly string _className;
+ private readonly IStatsDetailRepository _repo;
+
+ #endregion Private Fields
+ }
+}
\ No newline at end of file
diff --git a/MP.IOC/Program.cs b/MP.IOC/Program.cs
index 1d6a619d..f859ea94 100644
--- a/MP.IOC/Program.cs
+++ b/MP.IOC/Program.cs
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.OpenApi.Models;
+using MP.Data.Repository.Utils;
+using MP.Data.Services.Utils;
using MP.IOC.Data;
using MP.IOC.Services;
using NLog;
@@ -58,6 +60,17 @@ logger.Info("YARP reverse proxy configured");
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+
+// MP.Data DbContext for Stats repositories
+builder.Services.AddDbContextFactory(options =>
+ options.UseSqlServer(builder.Configuration.GetConnectionString("MP.Utils")));
+
+// MP.Data Services Utils - Statistiche DB
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
// generic controller
builder.Services.AddControllers();
diff --git a/MP.IOC/Services/MetricsDbFlushService.cs b/MP.IOC/Services/MetricsDbFlushService.cs
new file mode 100644
index 00000000..1d6dd0ee
--- /dev/null
+++ b/MP.IOC/Services/MetricsDbFlushService.cs
@@ -0,0 +1,132 @@
+using MP.Data.DbModels.Utils;
+using MP.Data.Services.Utils;
+using NLog;
+using System.Globalization;
+
+namespace MP.IOC.Services
+{
+ public class MetricsDbFlushService : BackgroundService
+ {
+ private const int FlushIntervalSeconds = 30;
+ private readonly RouteStatsManager _stats;
+ private readonly IStatsAggrService _aggrService;
+ private readonly IStatsDetailService _detailService;
+ private readonly IConfiguration _config;
+ private static readonly Logger Log = LogManager.GetCurrentClassLogger();
+
+ public MetricsDbFlushService(
+ RouteStatsManager stats,
+ IStatsAggrService aggrService,
+ IStatsDetailService detailService,
+ IConfiguration config)
+ {
+ _stats = stats;
+ _aggrService = aggrService;
+ _detailService = detailService;
+ _config = config;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var interval = _config.GetValue("RouteMan:FlushIntervalSeconds", FlushIntervalSeconds);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(interval), stoppingToken);
+ await FlushMetricsAsync();
+ }
+ catch (TaskCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error flushing metrics to database");
+ }
+ }
+ }
+
+ public async Task FlushMetricsAsync()
+ {
+ var snapshot = _stats.Snapshot();
+
+ if (snapshot.Count == 0)
+ {
+ return;
+ }
+
+ try
+ {
+ var utcNow = DateTime.Now;
+ var hourStart = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, utcNow.Hour, 0, 0);
+
+ var aggrRecords = new List();
+ var detailRecords = new List();
+
+ foreach (var kv in snapshot)
+ {
+ var method = kv.Key;
+ var stat = kv.Value;
+ var count = Interlocked.Read(ref stat.Count);
+ var totalMs = stat.TotalDuration.TotalMilliseconds;
+
+ if (count == 0) continue;
+
+ var avgDuration = totalMs / count;
+
+ var aggrRecord = new StatsAggregatedModel
+ {
+ Hour = hourStart,
+ RequestCount = count,
+ AvgDuration = avgDuration,
+ MaxDuration = totalMs,
+ Perc05Duration = 0,
+ Perc95Duration = 0,
+ NoReply = 0
+ };
+
+ aggrRecords.Add(aggrRecord);
+
+ foreach (var dest in stat.Destinations)
+ {
+ var detailRecord = new StatsDetailModel
+ {
+ Environment = dest.Key,
+ Type = method,
+ Hour = hourStart,
+ RequestCount = dest.Value,
+ AvgDuration = totalMs / dest.Value,
+ MaxDuration = totalMs,
+ Perc05Duration = 0,
+ Perc95Duration = 0,
+ NoReply = 0
+ };
+
+ detailRecords.Add(detailRecord);
+ }
+ }
+
+ if (aggrRecords.Count > 0)
+ {
+ await _aggrService.UpsertManyAsync(aggrRecords, true);
+ Log.Info("Flushed {count} aggregated stats records", aggrRecords.Count);
+ }
+
+ if (detailRecords.Count > 0)
+ {
+ await _detailService.UpsertManyAsync(detailRecords, true);
+ Log.Info("Flushed {count} detail stats records", detailRecords.Count);
+ }
+
+ _stats.Clear();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error processing metrics for database flush");
+ throw;
+ }
+ }
+ }
+}
diff --git a/MP.IOC/appsettings.json b/MP.IOC/appsettings.json
index 8ee72c39..5587b2d4 100644
--- a/MP.IOC/appsettings.json
+++ b/MP.IOC/appsettings.json
@@ -71,7 +71,6 @@
},
"RouteMan": {
"FlushIntervalSeconds": 60,
- //"FlushIntervalSeconds": 300,
"DefaultWeightOld": 100,
"DefaultWeightNew": 0
},
@@ -83,6 +82,7 @@
},
"ConnectionStrings": {
"MP.Data": "Server=SQL2016DEV;Database=MoonPro; User ID=sa;Password=keyhammer16; integrated security=False; MultipleActiveResultSets=True; App=MP.IOC;",
+ "MP.Utils": "Server=SQL2016DEV;Database=MoonPro_Utils; User ID=sa;Password=keyhammer16; integrated security=False; MultipleActiveResultSets=True; App=MP.IOC;",
"Redis": "redis.ufficio:26379,serviceName=devel,DefaultDatabase=5,connectTimeout=5000,syncTimeout=5000,asyncTimeout=5000,abortConnect=false,ssl=false",
"RedisAdmin": "redis.ufficio:26379,serviceName=devel,DefaultDatabase=5,connectTimeout=5000,syncTimeout=5000,asyncTimeout=5000,abortConnect=false,ssl=false,allowAdmin=true",
"mdbConnString": "mongodb://W2019-MONGODB:27017"