diff --git a/MP.Data/DbModels/Utils/StatsErrorModel.cs b/MP.Data/DbModels/Utils/StatsErrorModel.cs
new file mode 100644
index 00000000..57efc9ca
--- /dev/null
+++ b/MP.Data/DbModels/Utils/StatsErrorModel.cs
@@ -0,0 +1,17 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace MP.Data.DbModels.Utils
+{
+ [Table("stats_errors")]
+ public class StatsErrorModel
+ {
+ [Key] public int Id { get; set; }
+ public string Destination { get; set; } = "";
+ public string Type { get; set; } = "";
+ public DateTime Hour { get; set; }
+ public string ErrorMessage { get; set; } = "";
+ public long Count { get; set; }
+ }
+}
diff --git a/MP.Data/DbModels/Utils/StatsStatusCodeModel.cs b/MP.Data/DbModels/Utils/StatsStatusCodeModel.cs
new file mode 100644
index 00000000..18576c9b
--- /dev/null
+++ b/MP.Data/DbModels/Utils/StatsStatusCodeModel.cs
@@ -0,0 +1,17 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace MP.Data.DbModels.Utils
+{
+ [Table("stats_status_codes")]
+ public class StatsStatusCodeModel
+ {
+ [Key] public int Id { get; set; }
+ public string Destination { get; set; } = "";
+ public string Type { get; set; } = "";
+ public DateTime Hour { get; set; }
+ public int StatusCode { get; set; }
+ public long Count { get; set; }
+ }
+}
diff --git a/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.Designer.cs b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.Designer.cs
new file mode 100644
index 00000000..9acf271e
--- /dev/null
+++ b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.Designer.cs
@@ -0,0 +1,193 @@
+//
+using System;
+using MP.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace MP.Data.Migrations.MoonPro_Utils
+{
+ [DbContext(typeof(MoonPro_UtilsContext))]
+ [Migration("20260511071223_AddErrorsAndStatusCodes")]
+ partial class AddErrorsAndStatusCodes
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .UseCollation("SQL_Latin1_General_CP1_CI_AS")
+ .HasAnnotation("ProductVersion", "6.0.36")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1);
+
+ modelBuilder.Entity("MP.Data.DbModels.Mtc.MtcSetupModel", b =>
+ {
+ b.Property("IdxMacchina")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("MtcDataItemsRaw")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("IdxMacchina");
+
+ b.ToTable("mtc_setup");
+ });
+
+ modelBuilder.Entity("MP.Data.DbModels.Utils.StatsAggregatedModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1);
+
+ b.Property("AvgDuration")
+ .HasColumnType("float");
+
+ b.Property("Destination")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Hour")
+ .HasColumnType("datetime2");
+
+ b.Property("MachineId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("MaxDuration")
+ .HasColumnType("float");
+
+ b.Property("MinDuration")
+ .HasColumnType("float");
+
+ b.Property("NoReply")
+ .HasColumnType("bigint");
+
+ b.Property("RequestCount")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Destination", "MachineId", "Hour")
+ .IsUnique()
+ .HasDatabaseName("idx_statsaggr_env_mach_hour")
+ .HasFilter("[Destination] IS NOT NULL AND [MachineId] IS NOT NULL");
+
+ b.ToTable("stats_aggr");
+ });
+
+ modelBuilder.Entity("MP.Data.DbModels.Utils.StatsDetailModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1);
+
+ b.Property("AvgDuration")
+ .HasColumnType("float");
+
+ b.Property("Destination")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Hour")
+ .HasColumnType("datetime2");
+
+ b.Property("MaxDuration")
+ .HasColumnType("float");
+
+ b.Property("MinDuration")
+ .HasColumnType("float");
+
+ b.Property("NoReply")
+ .HasColumnType("bigint");
+
+ b.Property("RequestCount")
+ .HasColumnType("bigint");
+
+ b.Property("Type")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Destination", "Type", "Hour")
+ .IsUnique()
+ .HasDatabaseName("idx_statsdet_hour_env_type")
+ .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL");
+
+ b.ToTable("stats_detail");
+ });
+
+ modelBuilder.Entity("MP.Data.DbModels.Utils.StatsErrorModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1);
+
+ b.Property("Count")
+ .HasColumnType("bigint");
+
+ b.Property("Destination")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Hour")
+ .HasColumnType("datetime2");
+
+ b.Property("Type")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Destination", "Type", "Hour", "ErrorMessage")
+ .IsUnique()
+ .HasDatabaseName("idx_statserr_dest_type_hour_err")
+ .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL AND [ErrorMessage] IS NOT NULL");
+
+ b.ToTable("stats_errors");
+ });
+
+ modelBuilder.Entity("MP.Data.DbModels.Utils.StatsStatusCodeModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1);
+
+ b.Property("Count")
+ .HasColumnType("bigint");
+
+ b.Property("Destination")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Hour")
+ .HasColumnType("datetime2");
+
+ b.Property("StatusCode")
+ .HasColumnType("int");
+
+ b.Property("Type")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Destination", "Type", "Hour", "StatusCode")
+ .IsUnique()
+ .HasDatabaseName("idx_statscode_dest_type_hour_code")
+ .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL");
+
+ b.ToTable("stats_status_codes");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.cs b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.cs
new file mode 100644
index 00000000..abe01d3c
--- /dev/null
+++ b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.cs
@@ -0,0 +1,70 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace MP.Data.Migrations.MoonPro_Utils
+{
+ public partial class AddErrorsAndStatusCodes : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "stats_errors",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Destination = table.Column(type: "nvarchar(450)", nullable: true),
+ Type = table.Column(type: "nvarchar(450)", nullable: true),
+ Hour = table.Column(type: "datetime2", nullable: false),
+ ErrorMessage = table.Column(type: "nvarchar(450)", nullable: true),
+ Count = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_stats_errors", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "stats_status_codes",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Destination = table.Column(type: "nvarchar(450)", nullable: true),
+ Type = table.Column(type: "nvarchar(450)", nullable: true),
+ Hour = table.Column(type: "datetime2", nullable: false),
+ StatusCode = table.Column(type: "int", nullable: false),
+ Count = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_stats_status_codes", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "idx_statserr_dest_type_hour_err",
+ table: "stats_errors",
+ columns: new[] { "Destination", "Type", "Hour", "ErrorMessage" },
+ unique: true,
+ filter: "[Destination] IS NOT NULL AND [Type] IS NOT NULL AND [ErrorMessage] IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "idx_statscode_dest_type_hour_code",
+ table: "stats_status_codes",
+ columns: new[] { "Destination", "Type", "Hour", "StatusCode" },
+ unique: true,
+ filter: "[Destination] IS NOT NULL AND [Type] IS NOT NULL");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "stats_errors");
+
+ migrationBuilder.DropTable(
+ name: "stats_status_codes");
+ }
+ }
+}
diff --git a/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs b/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs
index 531fb160..aea0326e 100644
--- a/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs
+++ b/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs
@@ -119,6 +119,72 @@ namespace MP.Data.Migrations.MoonPro_Utils
b.ToTable("stats_detail");
});
+
+ modelBuilder.Entity("MP.Data.DbModels.Utils.StatsErrorModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1);
+
+ b.Property("Count")
+ .HasColumnType("bigint");
+
+ b.Property("Destination")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Hour")
+ .HasColumnType("datetime2");
+
+ b.Property("Type")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Destination", "Type", "Hour", "ErrorMessage")
+ .IsUnique()
+ .HasDatabaseName("idx_statserr_dest_type_hour_err")
+ .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL AND [ErrorMessage] IS NOT NULL");
+
+ b.ToTable("stats_errors");
+ });
+
+ modelBuilder.Entity("MP.Data.DbModels.Utils.StatsStatusCodeModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1);
+
+ b.Property("Count")
+ .HasColumnType("bigint");
+
+ b.Property("Destination")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Hour")
+ .HasColumnType("datetime2");
+
+ b.Property("StatusCode")
+ .HasColumnType("int");
+
+ b.Property("Type")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Destination", "Type", "Hour", "StatusCode")
+ .IsUnique()
+ .HasDatabaseName("idx_statscode_dest_type_hour_code")
+ .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL");
+
+ b.ToTable("stats_status_codes");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/MP.Data/MoonPro_UtilsContext.cs b/MP.Data/MoonPro_UtilsContext.cs
index 2875ed97..edb248e1 100644
--- a/MP.Data/MoonPro_UtilsContext.cs
+++ b/MP.Data/MoonPro_UtilsContext.cs
@@ -49,6 +49,8 @@ namespace MP.Data
public virtual DbSet DbSetStatsDet { get; set; }
public virtual DbSet DbSetStatsAggr { get; set; }
public virtual DbSet DbSetMtcSetup { get; set; }
+ public virtual DbSet DbSetStatsError { get; set; }
+ public virtual DbSet DbSetStatusCode { get; set; }
#endregion Public Properties
@@ -91,6 +93,16 @@ namespace MP.Data
.HasDatabaseName("idx_statsaggr_env_mach_hour")
.IsUnique();
+ modelBuilder.Entity()
+ .HasIndex(x => new { x.Destination, x.Type, x.Hour, x.ErrorMessage })
+ .HasDatabaseName("idx_statserr_dest_type_hour_err")
+ .IsUnique();
+
+ modelBuilder.Entity()
+ .HasIndex(x => new { x.Destination, x.Type, x.Hour, x.StatusCode })
+ .HasDatabaseName("idx_statscode_dest_type_hour_code")
+ .IsUnique();
+
OnModelCreatingPartial(modelBuilder);
}
diff --git a/MP.Data/Repository/Utils/IStatsCodeRepository.cs b/MP.Data/Repository/Utils/IStatsCodeRepository.cs
new file mode 100644
index 00000000..fb38eed8
--- /dev/null
+++ b/MP.Data/Repository/Utils/IStatsCodeRepository.cs
@@ -0,0 +1,36 @@
+using EgwCoreLib.Utils;
+using MP.Data.DbModels.Utils;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace MP.Data.Repository.Utils
+{
+ ///
+ /// Gestione statistica StatusCode HTML
+ ///
+ public interface IStatsCodeRepository
+ {
+ ///
+ /// Recupera l'elenco delle statistiche StatusCode 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 StatusCode.
+ ///
+ /// L'intervallo di date (minima e massima ora presente).
+ Task GetRangeAsync();
+
+ ///
+ /// Inserisce o aggiorna in blocco le statistiche StatusCode 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);
+ }
+}
diff --git a/MP.Data/Repository/Utils/IStatsErrRepository.cs b/MP.Data/Repository/Utils/IStatsErrRepository.cs
new file mode 100644
index 00000000..fdd704ec
--- /dev/null
+++ b/MP.Data/Repository/Utils/IStatsErrRepository.cs
@@ -0,0 +1,36 @@
+using EgwCoreLib.Utils;
+using MP.Data.DbModels.Utils;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace MP.Data.Repository.Utils
+{
+ ///
+ /// Gestione statistiche errori
+ ///
+ public interface IStatsErrRepository
+ {
+ ///
+ /// Recupera l'elenco delle statistiche errori per un periodo specifico.
+ ///
+ /// La data di inizio del periodo.
+ /// La data di fine del periodo.
+ /// L'elenco delle statistiche errori ordinate cronologicamente.
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd);
+
+ ///
+ /// Recupera l'intervallo temporale disponibile nel database per le statistiche errore.
+ ///
+ /// L'intervallo di date (minima e massima ora presente).
+ Task GetRangeAsync();
+
+ ///
+ /// Inserisce o aggiorna in blocco le statistiche errore 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);
+ }
+}
diff --git a/MP.Data/Repository/Utils/StatsCodeRepository.cs b/MP.Data/Repository/Utils/StatsCodeRepository.cs
new file mode 100644
index 00000000..af6871d3
--- /dev/null
+++ b/MP.Data/Repository/Utils/StatsCodeRepository.cs
@@ -0,0 +1,141 @@
+using EgwCoreLib.Utils;
+using Microsoft.EntityFrameworkCore;
+using MP.Data.DbModels.Utils;
+using NLog;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace MP.Data.Repository.Utils
+{
+ public class StatsCodeRepository : BaseRepository, IStatsCodeRepository
+ {
+ #region Public Constructors
+
+ public StatsCodeRepository(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
+ .DbSetStatusCode
+ .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.DbSetStatusCode.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)
+ {
+ if (listRecords == null || !listRecords.Any()) return 0;
+
+ int ans = 0;
+ await using var dbCtx = await CreateContextAsync();
+ await using var tx = await dbCtx.Database.BeginTransactionAsync();
+
+ try
+ {
+ // 1. Calcolo del range temporale della lista in arrivo per limitare la query di ricerca
+ var minHour = listRecords.Min(x => x.Hour);
+ var maxHour = listRecords.Max(x => x.Hour);
+
+ // 2. Se removeOld è true, manteniamo la logica originale (Eliminazione distruttiva)
+ if (removeOld)
+ {
+ // uso direttamente ExecuteDelete quando in EFCore8...
+#if false
+ await dbCtx
+ .DbSetStatusCode
+ .Where(x => x.Hour >= startDate && x.Hour <= endDate)
+ .ExecuteDeleteAsync();
+#endif
+
+ var itemsToRemove = await dbCtx.DbSetStatusCode
+ .Where(x => x.Hour >= minHour && x.Hour <= maxHour)
+ .ToListAsync();
+ if (itemsToRemove.Any())
+ {
+ dbCtx.DbSetStatusCode.RemoveRange(itemsToRemove);
+ await dbCtx.SaveChangesAsync(); // Commit parziale per la cancellazione
+ }
+ }
+
+ // 3. LOGICA DI UPSERT (Merge)
+ // Recuperiamo tutti i record esistenti nel database che cadono nello stesso range temporale
+ // Questo ci permette di confrontare ciò che arriva con ciò che è già presente.
+ var existingRecords = await dbCtx.DbSetStatusCode
+ .Where(x => x.Hour >= minHour && x.Hour <= maxHour)
+ .ToListAsync();
+
+ // Creiamo un dizionario per ricerca rapida O(1) basato sulla chiave univoca (Dest + Hour)
+ // Usiamo una Tupla come chiave del dizionario
+ var lookup = existingRecords.ToDictionary(
+ x => (x.Destination, x.Type, x.Hour, x.StatusCode),
+ x => x
+ );
+
+ foreach (var incoming in listRecords)
+ {
+ var key = (incoming.Destination, incoming.Type, incoming.Hour, incoming.StatusCode);
+ if (lookup.TryGetValue(key, out var existing))
+ {
+ // --- CASO: UPDATE ---
+ existing.Count = incoming.Count;
+ }
+ else
+ {
+ // --- CASO: INSERT ---
+ await dbCtx.DbSetStatusCode.AddAsync(incoming);
+ }
+ }
+ // 4. Salvataggio finale
+ ans = await dbCtx.SaveChangesAsync();
+
+ // Commit della transazione
+ await tx.CommitAsync();
+
+ // Pulizia memoria per evitare che il ChangeTracker diventi troppo pesante nei loop lunghi
+ dbCtx.ChangeTracker.Clear();
+
+ return ans;
+ }
+ catch (Exception ex)
+ {
+ await tx.RollbackAsync();
+ Log.Error(ex, "Error during UpsertManyAsync");
+ throw;
+ }
+ }
+
+ #endregion Public Methods
+
+ #region Protected Fields
+
+ protected static NLog.Logger Log = LogManager.GetCurrentClassLogger();
+
+ #endregion Protected Fields
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Repository/Utils/StatsErrRepository.cs b/MP.Data/Repository/Utils/StatsErrRepository.cs
new file mode 100644
index 00000000..c916d9de
--- /dev/null
+++ b/MP.Data/Repository/Utils/StatsErrRepository.cs
@@ -0,0 +1,141 @@
+using EgwCoreLib.Utils;
+using Microsoft.EntityFrameworkCore;
+using MP.Data.DbModels.Utils;
+using NLog;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace MP.Data.Repository.Utils
+{
+ public class StatsErrRepository : BaseRepository, IStatsErrRepository
+ {
+ #region Public Constructors
+
+ public StatsErrRepository(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
+ .DbSetStatsError
+ .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.DbSetStatsError.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)
+ {
+ if (listRecords == null || !listRecords.Any()) return 0;
+
+ int ans = 0;
+ await using var dbCtx = await CreateContextAsync();
+ await using var tx = await dbCtx.Database.BeginTransactionAsync();
+
+ try
+ {
+ // 1. Calcolo del range temporale della lista in arrivo per limitare la query di ricerca
+ var minHour = listRecords.Min(x => x.Hour);
+ var maxHour = listRecords.Max(x => x.Hour);
+
+ // 2. Se removeOld è true, manteniamo la logica originale (Eliminazione distruttiva)
+ if (removeOld)
+ {
+ // uso direttamente ExecuteDelete quando in EFCore8...
+#if false
+ await dbCtx
+ .DbSetStatsError
+ .Where(x => x.Hour >= startDate && x.Hour <= endDate)
+ .ExecuteDeleteAsync();
+#endif
+
+ var itemsToRemove = await dbCtx.DbSetStatsError
+ .Where(x => x.Hour >= minHour && x.Hour <= maxHour)
+ .ToListAsync();
+ if (itemsToRemove.Any())
+ {
+ dbCtx.DbSetStatsError.RemoveRange(itemsToRemove);
+ await dbCtx.SaveChangesAsync(); // Commit parziale per la cancellazione
+ }
+ }
+
+ // 3. LOGICA DI UPSERT (Merge)
+ // Recuperiamo tutti i record esistenti nel database che cadono nello stesso range temporale
+ // Questo ci permette di confrontare ciò che arriva con ciò che è già presente.
+ var existingRecords = await dbCtx.DbSetStatsError
+ .Where(x => x.Hour >= minHour && x.Hour <= maxHour)
+ .ToListAsync();
+
+ // Creiamo un dizionario per ricerca rapida O(1) basato sulla chiave univoca (Dest + Hour)
+ // Usiamo una Tupla come chiave del dizionario
+ var lookup = existingRecords.ToDictionary(
+ x => (x.Destination, x.Type, x.Hour, x.ErrorMessage),
+ x => x
+ );
+
+ foreach (var incoming in listRecords)
+ {
+ var key = (incoming.Destination, incoming.Type, incoming.Hour, incoming.ErrorMessage);
+ if (lookup.TryGetValue(key, out var existing))
+ {
+ // --- CASO: UPDATE ---
+ existing.Count = incoming.Count;
+ }
+ else
+ {
+ // --- CASO: INSERT ---
+ await dbCtx.DbSetStatsError.AddAsync(incoming);
+ }
+ }
+ // 4. Salvataggio finale
+ ans = await dbCtx.SaveChangesAsync();
+
+ // Commit della transazione
+ await tx.CommitAsync();
+
+ // Pulizia memoria per evitare che il ChangeTracker diventi troppo pesante nei loop lunghi
+ dbCtx.ChangeTracker.Clear();
+
+ return ans;
+ }
+ catch (Exception ex)
+ {
+ await tx.RollbackAsync();
+ Log.Error(ex, "Error during UpsertManyAsync");
+ throw;
+ }
+ }
+
+ #endregion Public Methods
+
+ #region Protected Fields
+
+ protected static NLog.Logger Log = LogManager.GetCurrentClassLogger();
+
+ #endregion Protected Fields
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Services/Utils/IStatsAggrService.cs b/MP.Data/Services/Utils/IStatsAggrService.cs
index a1905070..6723a860 100644
--- a/MP.Data/Services/Utils/IStatsAggrService.cs
+++ b/MP.Data/Services/Utils/IStatsAggrService.cs
@@ -20,13 +20,6 @@ namespace MP.Data.Services.Utils
/// Data fine periodo
Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd);
- ///
- /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale
- /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
- ///
- ///
- Task>> GetParetoStatsWeekAsync();
-
///
/// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte giornaliero
/// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
@@ -34,19 +27,19 @@ namespace MP.Data.Services.Utils
///
Task>> GetParetoStatsDayAsync(int numDay);
+ ///
+ /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale
+ /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
+ ///
+ ///
+ Task>> GetParetoStatsWeekAsync();
+
///
/// 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);
///
/// Helper conversione dati aggregati in statistiche da inviare a ChartJS
///
@@ -56,6 +49,14 @@ namespace MP.Data.Services.Utils
///
List GetTimeSeriesData(List rawData, bool groupMach, bool getCount);
+ ///
+ /// 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/IStatsCodeService.cs b/MP.Data/Services/Utils/IStatsCodeService.cs
new file mode 100644
index 00000000..10c0239b
--- /dev/null
+++ b/MP.Data/Services/Utils/IStatsCodeService.cs
@@ -0,0 +1,60 @@
+using EgwCoreLib.Utils;
+using MP.Core.DTO;
+using MP.Data.DbModels.Utils;
+using MP.Data.DTO;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace MP.Data.Services.Utils
+{
+ public interface IStatsCodeService
+ {
+ #region Public Methods
+
+ ///
+ /// Recupera l'elenco delle statistiche StatusCode per un periodo specificato.
+ /// Utilizza la cache automaticamente.
+ ///
+ /// Data inizio periodo
+ /// Data fine periodo
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd);
+
+ ///
+ /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte giornaliero
+ /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
+ ///
+ ///
+ Task>> GetParetoStatsDayAsync(int numDay);
+
+ ///
+ /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale
+ /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
+ ///
+ ///
+ Task>> GetParetoStatsWeekAsync();
+
+ ///
+ /// Recupera il range di periodi valido per le chiamate StatusCode.
+ /// Utilizza la cache automaticamente.
+ ///
+ Task GetRangeAsync();
+
+ ///
+ /// Helper conversione dati aggregati in statistiche da inviare a ChartJS
+ ///
+ ///
+ ///
+ List GetTimeSeriesData(List rawData);
+
+ ///
+ /// Inserisce o aggiorna in batch le statistiche StatusCode 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/IStatsErrService.cs b/MP.Data/Services/Utils/IStatsErrService.cs
new file mode 100644
index 00000000..3141c7e9
--- /dev/null
+++ b/MP.Data/Services/Utils/IStatsErrService.cs
@@ -0,0 +1,60 @@
+using EgwCoreLib.Utils;
+using MP.Core.DTO;
+using MP.Data.DbModels.Utils;
+using MP.Data.DTO;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace MP.Data.Services.Utils
+{
+ public interface IStatsErrService
+ {
+ #region Public Methods
+
+ ///
+ /// Recupera l'elenco delle statistiche Error per un periodo specificato.
+ /// Utilizza la cache automaticamente.
+ ///
+ /// Data inizio periodo
+ /// Data fine periodo
+ Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd);
+
+ ///
+ /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte giornaliero
+ /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
+ ///
+ ///
+ Task>> GetParetoStatsDayAsync(int numDay);
+
+ ///
+ /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale
+ /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica
+ ///
+ ///
+ Task>> GetParetoStatsWeekAsync();
+
+ ///
+ /// Recupera il range di periodi valido per le chiamate Error.
+ /// Utilizza la cache automaticamente.
+ ///
+ Task GetRangeAsync();
+
+ ///
+ /// Helper conversione dati aggregati in statistiche da inviare a ChartJS
+ ///
+ ///
+ ///
+ List GetTimeSeriesData(List rawData);
+
+ ///
+ /// Inserisce o aggiorna in batch le statistiche Error 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/StatsCodeService.cs b/MP.Data/Services/Utils/StatsCodeService.cs
new file mode 100644
index 00000000..3d2bf4df
--- /dev/null
+++ b/MP.Data/Services/Utils/StatsCodeService.cs
@@ -0,0 +1,186 @@
+using EgwCoreLib.Utils;
+using Microsoft.Extensions.Configuration;
+using MP.Core.DTO;
+using MP.Data.DbModels.Utils;
+using MP.Data.DTO;
+using MP.Data.Repository.Utils;
+using StackExchange.Redis;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace MP.Data.Services.Utils
+{
+ internal class StatsCodeService : BaseServ, IStatsCodeService
+ {
+ #region Public Constructors
+
+ public StatsCodeService(
+ IConfiguration config,
+ IConnectionMultiplexer redis,
+ IStatsCodeRepository repo) : base(config, redis)
+ {
+ _className = "StatsStatusCode";
+ _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>> GetParetoStatsDayAsync(int numDay)
+ {
+ return await TraceAsync($"{_className}.GetParetoStatsDayAsync", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:ParetoDay",
+ async () => await GetParetoDestAsync(numDay),
+ LongCache
+ );
+ });
+ }
+
+ ///
+ public async Task>> GetParetoStatsWeekAsync()
+ {
+ return await TraceAsync($"{_className}.GetParetoStatsWeekAsync", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:ParetoWeek",
+ async () => await GetParetoDataAsync(),
+ LongCache
+ );
+ });
+ }
+
+ ///
+ public async Task GetRangeAsync()
+ {
+ return await TraceAsync($"{_className}.GetRange", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:Range",
+ async () => await _repo.GetRangeAsync(),
+ UltraFastCache
+ );
+ });
+ }
+
+ ///
+ public List GetTimeSeriesData(List rawData)
+ {
+ DateTime adesso = DateTime.Now;
+
+ List series = new();
+ series = rawData
+ .GroupBy(s => new { s.Destination })
+ .Select(group => new ChartSeriesDto
+ {
+ SeriesName = group.Key.Destination,
+ DataPoints = group
+ .OrderBy(p => p.Hour)
+ .Select(p => new chartJsData.chartJsTSerie
+ {
+ x = p.Hour,
+ y = p.Count / (p.Hour.Date.Equals(adesso.Date) ? adesso.TimeOfDay.TotalHours : 24)
+ })
+ .ToList()
+ })
+ .ToList();
+
+ return series;
+ }
+
+ ///
+ 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 Protected Methods
+
+ ///
+ /// metodo locale per recupero e trasformazione dati da includere con processo generare di tracking & cache
+ ///
+ ///
+ protected async Task>> GetParetoDataAsync()
+ {
+ Dictionary> result = new();
+ DateTime oggi = DateTime.Today;
+ int numDays = 7;
+ var rawData = await GetFiltAsync(oggi.AddDays(-numDays), oggi);
+ // calcolo le varie statistiche...
+ var pDestRequest = rawData.GroupBy(x => x.Destination)
+ .Select(g => new StatDataDTO
+ {
+ Label = g.Key,
+ Value = g.Sum(x => x.Count) / numDays
+ })
+ .OrderByDescending(x => x.Value)
+ .ToList();
+ result.Add("Errors (#)", pDestRequest);
+
+ return result;
+ }
+
+ protected async Task>> GetParetoDestAsync(int numDay)
+ {
+ Dictionary> result = new();
+ DateTime adesso = DateTime.Now;
+ DateTime start = DateTime.Today.AddDays(-numDay);
+ var rawData = await GetFiltAsync(start, adesso);
+ var numHour = adesso.Subtract(start).TotalHours;
+ // calcolo le varie statistiche...
+ var pDestRequest = rawData.GroupBy(x => x.Destination)
+ .Select(g => new StatDataDTO
+ {
+ Label = g.Key,
+ Value = g.Sum(x => x.Count) / numHour
+ })
+ .OrderByDescending(x => x.Value)
+ .ToList();
+ result.Add("Dest.Request (#/h)", pDestRequest);
+
+ return result;
+ }
+
+ #endregion Protected Methods
+
+ #region Private Fields
+
+ private readonly string _className;
+ private readonly IStatsCodeRepository _repo;
+
+ #endregion Private Fields
+ }
+}
\ No newline at end of file
diff --git a/MP.Data/Services/Utils/StatsErrService.cs b/MP.Data/Services/Utils/StatsErrService.cs
new file mode 100644
index 00000000..26e76309
--- /dev/null
+++ b/MP.Data/Services/Utils/StatsErrService.cs
@@ -0,0 +1,186 @@
+using EgwCoreLib.Utils;
+using Microsoft.Extensions.Configuration;
+using MP.Core.DTO;
+using MP.Data.DbModels.Utils;
+using MP.Data.DTO;
+using MP.Data.Repository.Utils;
+using StackExchange.Redis;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace MP.Data.Services.Utils
+{
+ public class StatsErrService : BaseServ, IStatsErrService
+ {
+ #region Public Constructors
+
+ public StatsErrService(
+ IConfiguration config,
+ IConnectionMultiplexer redis,
+ IStatsErrRepository repo) : base(config, redis)
+ {
+ _className = "StatsErr";
+ _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>> GetParetoStatsDayAsync(int numDay)
+ {
+ return await TraceAsync($"{_className}.GetParetoStatsDayAsync", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:ParetoDay",
+ async () => await GetParetoDestAsync(numDay),
+ LongCache
+ );
+ });
+ }
+
+ ///
+ public async Task>> GetParetoStatsWeekAsync()
+ {
+ return await TraceAsync($"{_className}.GetParetoStatsWeekAsync", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:ParetoWeek",
+ async () => await GetParetoDataAsync(),
+ LongCache
+ );
+ });
+ }
+
+ ///
+ public async Task GetRangeAsync()
+ {
+ return await TraceAsync($"{_className}.GetRange", async (activity) =>
+ {
+ return await GetOrSetCacheAsync(
+ $"{_redisBaseKey}:{_className}:Range",
+ async () => await _repo.GetRangeAsync(),
+ UltraFastCache
+ );
+ });
+ }
+
+ ///
+ public List GetTimeSeriesData(List rawData)
+ {
+ DateTime adesso = DateTime.Now;
+
+ List series = new();
+ series = rawData
+ .GroupBy(s => new { s.Destination })
+ .Select(group => new ChartSeriesDto
+ {
+ SeriesName = group.Key.Destination,
+ DataPoints = group
+ .OrderBy(p => p.Hour)
+ .Select(p => new chartJsData.chartJsTSerie
+ {
+ x = p.Hour,
+ y = p.Count / (p.Hour.Date.Equals(adesso.Date) ? adesso.TimeOfDay.TotalHours : 24)
+ })
+ .ToList()
+ })
+ .ToList();
+
+ return series;
+ }
+
+ ///
+ 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 Protected Methods
+
+ ///
+ /// metodo locale per recupero e trasformazione dati da includere con processo generare di tracking & cache
+ ///
+ ///
+ protected async Task>> GetParetoDataAsync()
+ {
+ Dictionary> result = new();
+ DateTime oggi = DateTime.Today;
+ int numDays = 7;
+ var rawData = await GetFiltAsync(oggi.AddDays(-numDays), oggi);
+ // calcolo le varie statistiche...
+ var pDestRequest = rawData.GroupBy(x => x.Destination)
+ .Select(g => new StatDataDTO
+ {
+ Label = g.Key,
+ Value = g.Sum(x => x.Count) / numDays
+ })
+ .OrderByDescending(x => x.Value)
+ .ToList();
+ result.Add("Errors (#)", pDestRequest);
+
+ return result;
+ }
+
+ protected async Task>> GetParetoDestAsync(int numDay)
+ {
+ Dictionary> result = new();
+ DateTime adesso = DateTime.Now;
+ DateTime start = DateTime.Today.AddDays(-numDay);
+ var rawData = await GetFiltAsync(start, adesso);
+ var numHour = adesso.Subtract(start).TotalHours;
+ // calcolo le varie statistiche...
+ var pDestRequest = rawData.GroupBy(x => x.Destination)
+ .Select(g => new StatDataDTO
+ {
+ Label = g.Key,
+ Value = g.Sum(x => x.Count) / numHour
+ })
+ .OrderByDescending(x => x.Value)
+ .ToList();
+ result.Add("Dest.Request (#/h)", pDestRequest);
+
+ return result;
+ }
+
+ #endregion Protected Methods
+
+ #region Private Fields
+
+ private readonly string _className;
+ private readonly IStatsErrRepository _repo;
+
+ #endregion Private Fields
+ }
+}
\ No newline at end of file
diff --git a/MP.IOC/Data/MpDataService.cs b/MP.IOC/Data/MpDataService.cs
index abce85b4..922965f3 100644
--- a/MP.IOC/Data/MpDataService.cs
+++ b/MP.IOC/Data/MpDataService.cs
@@ -4054,7 +4054,7 @@ namespace MP.IOC.Data
{
Log.Info($"upsertCurrObjItems | idxMacchina: {idxMacchina} | {innovations.Count} innovations");
// leggo i valori attuali...
- List actValues = MachineParamList(idxMacchina);
+ List actValues = await MachineParamListAsync(idxMacchina);
// per ogni valOut passatomi faccio insert o update rispetto elenco valori correnti in REDIS
foreach (var item in actValues)
{
@@ -4078,7 +4078,7 @@ namespace MP.IOC.Data
string serVal = JsonConvert.SerializeObject(innovations);
var currKey = Utils.RedKeyCurrObjItems(idxMacchina, MpIoNS);
- RedisValue rawData = redisDb.StringSet(currKey, serVal);
+ RedisValue rawData = await redisDb.StringSetAsync(currKey, serVal);
}
return answ;
}
diff --git a/MP.RIOC/MP.RIOC.csproj b/MP.RIOC/MP.RIOC.csproj
index a7f0e7f1..5b179860 100644
--- a/MP.RIOC/MP.RIOC.csproj
+++ b/MP.RIOC/MP.RIOC.csproj
@@ -5,7 +5,7 @@
enable
enable
MP.RIOC
- 8.16.2605.910
+ 8.16.2605.1111
diff --git a/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua b/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua
index 1b4a2477..7e12447d 100644
--- a/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua
+++ b/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua
@@ -4,11 +4,13 @@ local countInc = tonumber(ARGV[1]) or 0
local totalMsInc = tonumber(ARGV[2]) or 0
local newMax = tonumber(ARGV[3])
local newMin = tonumber(ARGV[4])
-local sentinel = tonumber(ARGV[5])
+local noReply = tonumber(ARGV[5])
+local sentinel = tonumber(ARGV[6])
--- Incrementi base
+-- Incrementi
redis.call('HINCRBY', key, 'count', countInc)
redis.call('HINCRBYFLOAT', key, 'totalMs', totalMsInc)
+redis.call('HINCRBY', key, 'noReply', noReply)
-- MAX
local currentMaxStr = redis.call('HGET', key, 'maxMs')
diff --git a/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua b/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua
index 230ed7f9..0e0cc274 100644
--- a/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua
+++ b/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua
@@ -4,11 +4,13 @@ local countInc = tonumber(ARGV[1]) or 0
local totalMsInc = tonumber(ARGV[2]) or 0
local newMax = tonumber(ARGV[3])
local newMin = tonumber(ARGV[4])
-local sentinel = tonumber(ARGV[5])
+local noReply = tonumber(ARGV[5])
+local sentinel = tonumber(ARGV[6])
-- Incrementi
redis.call('HINCRBY', key, 'count', countInc)
redis.call('HINCRBYFLOAT', key, 'totalMs', totalMsInc)
+redis.call('HINCRBY', key, 'noReply', noReply)
-- MAX
local currentMaxStr = redis.call('HGET', key, 'maxMs')
diff --git a/MP.RIOC/Resources/ChangeLog.html b/MP.RIOC/Resources/ChangeLog.html
index 4bf268b2..e2824b7f 100644
--- a/MP.RIOC/Resources/ChangeLog.html
+++ b/MP.RIOC/Resources/ChangeLog.html
@@ -1,6 +1,6 @@
Modulo MP-RIOC
- Versione: 8.16.2605.910
+ Versione: 8.16.2605.1111
Note di rilascio:
-
diff --git a/MP.RIOC/Resources/VersNum.txt b/MP.RIOC/Resources/VersNum.txt
index c8db13cb..dcaf613a 100644
--- a/MP.RIOC/Resources/VersNum.txt
+++ b/MP.RIOC/Resources/VersNum.txt
@@ -1 +1 @@
-8.16.2605.910
+8.16.2605.1111
diff --git a/MP.RIOC/Resources/manifest.xml b/MP.RIOC/Resources/manifest.xml
index f74a5184..d59bada6 100644
--- a/MP.RIOC/Resources/manifest.xml
+++ b/MP.RIOC/Resources/manifest.xml
@@ -1,6 +1,6 @@
-
- 8.16.2605.910
+ 8.16.2605.1111
https://nexus.steamware.net/repository/SWS/MP-RIOC/stable/LAST/MP.RIOC.zip
https://nexus.steamware.net/repository/SWS/MP-RIOC/stable/LAST/ChangeLog.html
false
diff --git a/MP.RIOC/Services/MetricsCalcService.cs b/MP.RIOC/Services/MetricsCalcService.cs
index 81c164c4..39fe50cc 100644
--- a/MP.RIOC/Services/MetricsCalcService.cs
+++ b/MP.RIOC/Services/MetricsCalcService.cs
@@ -48,6 +48,7 @@ namespace MP.RIOC.Services
private class AggregatedStats
{
public long Count;
+ public long NoReply;
public double TotalMs;
public double MaxMs;
public double MinMs = double.MaxValue;
@@ -111,6 +112,10 @@ namespace MP.RIOC.Services
var hoursIndex = HoursIndexKey(dest, method);
var hourScore = ToEpochSeconds(hourStart);
+ // Calcolo NoReply: Somma di tutti i codici >= 400 o errori espliciti
+ long noReplyCount = stat.ErrorMessages.Sum(x => x.Value);
+
+ // 1. INVIO BUCKET PRINCIPALE (con NoReply)
// Usiamo lo script Lua per l'aggiornamento atomico dell'ora
tasks.Add(batch.ScriptEvaluateAsync(_updateScript,
new RedisKey[] { hourKey },
@@ -119,9 +124,34 @@ namespace MP.RIOC.Services
totalMs.ToString(CultureInfo.InvariantCulture),
maxMs.ToString(CultureInfo.InvariantCulture),
minMs.ToString(CultureInfo.InvariantCulture),
+ noReplyCount.ToString(CultureInfo.InvariantCulture),
SentinelValue
}));
+ // 2. INVIO DISTRIBUZIONE STATUS CODES
+ if (stat.StatusCodes.Any())
+ {
+ var statusKey = hourKey + ":status";
+ foreach (var status in stat.StatusCodes)
+ {
+ tasks.Add(batch.HashIncrementAsync(statusKey, status.Key.ToString(), status.Value));
+ }
+ // Aggiungiamo anche questa chiave all'indice per la pulizia automatica
+ tasks.Add(batch.SortedSetAddAsync(hoursIndex, statusKey, hourScore));
+ }
+
+ // 3. INVIO DETTAGLIO ERRORI
+ if (stat.ErrorMessages.Any())
+ {
+ var errorKey = hourKey + ":errors";
+ foreach (var error in stat.ErrorMessages)
+ {
+ // Usiamo HashIncrement per aggregare messaggi uguali
+ tasks.Add(batch.HashIncrementAsync(errorKey, error.Key, error.Value));
+ }
+ tasks.Add(batch.SortedSetAddAsync(hoursIndex, errorKey, hourScore));
+ }
+
tasks.Add(batch.SortedSetAddAsync(hoursIndex, hourKey, hourScore));
// --- LOGICA DAILY (Aggregazione locale per evitare sovrascritture nel loop) ---
@@ -136,6 +166,7 @@ namespace MP.RIOC.Services
}
agg.Count += count;
+ agg.NoReply += noReplyCount;
agg.TotalMs += totalMs;
agg.MaxMs = Math.Max(agg.MaxMs, maxMs);
if (minMs != double.MaxValue)
@@ -162,6 +193,7 @@ namespace MP.RIOC.Services
agg.TotalMs.ToString(CultureInfo.InvariantCulture),
agg.MaxMs.ToString(CultureInfo.InvariantCulture),
finalMin.ToString(CultureInfo.InvariantCulture),
+ agg.NoReply.ToString(CultureInfo.InvariantCulture),
SentinelValue
}));
}
diff --git a/MP.RIOC/Services/MetricsDbFlushService.cs b/MP.RIOC/Services/MetricsDbFlushService.cs
index ba6444b2..4d2cbd71 100644
--- a/MP.RIOC/Services/MetricsDbFlushService.cs
+++ b/MP.RIOC/Services/MetricsDbFlushService.cs
@@ -27,7 +27,7 @@ namespace MP.RIOC.Services
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
- var interval = _config.GetValue("RouteMan:MetricFlushIntervalSeconds", 180);
+ var interval = _config.GetValue("RouteMan:MetricFlushIntervalSeconds", 240);
while (!stoppingToken.IsCancellationRequested)
{
@@ -79,7 +79,7 @@ namespace MP.RIOC.Services
DateTime now = DateTime.Now;
// Confini temporali per proteggere i dati in corso
- DateTime currentDayStart = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0);
+ DateTime currentDayStart = DateTime.Today;
var endpoints = _mux.GetEndPoints();
@@ -110,17 +110,14 @@ namespace MP.RIOC.Services
foreach (var statKey in memberKeys)
{
var sKey = (RedisKey)$"{statKey}";
+#if false
if (!TryParseKeyMetadata(sKey, out string dest, out string method, out string machId, out DateTime timestamp, out bool isHourType))
- continue;
-
- // Verifica se la chiave fosse scaduta rispetto all'orario corrente
- bool isExpired = isHourType
- //? timestamp < currentHourStart
- ? false
- : timestamp < currentDayStart;
+ continue;
+#endif
+ if (!TryParseKeyMetadata(sKey, out var meta) || meta.IsHourType) continue;
// Se fosse scaduta e abbiamo il permesso, segnamola per la cancellazione
- if (isExpired && deleteConfirmed)
+ if (meta.Timestamp < currentDayStart && deleteConfirmed)
{
// 1. Segna la chiave Hash (Dati) per l'eliminazione
keysToDelete.Add(sKey);
@@ -152,16 +149,23 @@ namespace MP.RIOC.Services
if (count <= 0) continue;
+ // recupero se presente noReply
+ long noReply = 0;
+ if (dict.TryGetValue("noReply", out var sNoReply))
+ {
+ noReply = long.Parse(sNoReply);
+ }
+
aggrRecordsToInsert.Add(new StatsAggregatedModel
{
- Destination = dest,
- MachineId = machId,
- Hour = timestamp,
+ Destination = meta.Dest,
+ MachineId = meta.MachId,
+ Hour = meta.Timestamp,
RequestCount = count,
AvgDuration = totalMs / count,
MinDuration = minMs,
MaxDuration = maxMs,
- NoReply = 0
+ NoReply = noReply
});
}
}
@@ -171,7 +175,7 @@ namespace MP.RIOC.Services
}
// --- FASE UPSERT DB ---
- if (aggrRecordsToInsert.Count > 0)
+ if (aggrRecordsToInsert.Any())
{
await using var scope = _scopeFactory.CreateAsyncScope();
var aggrService = scope.ServiceProvider.GetRequiredService();
@@ -180,7 +184,7 @@ namespace MP.RIOC.Services
}
// --- FASE PULIZIA REDIS ---
- if (deleteConfirmed && keysToDelete.Count > 0)
+ if (deleteConfirmed && keysToDelete.Any())
{
var batch = _db.CreateBatch();
int deletedCount = 0;
@@ -239,17 +243,20 @@ namespace MP.RIOC.Services
foreach (var statKey in memberKeys)
{
var sKey = (RedisKey)$"{statKey}";
+ if (!TryParseKeyMetadata(sKey, out var meta) || !meta.IsHourType) continue;
+#if false
if (!TryParseKeyMetadata(sKey, out string dest, out string method, out string machId, out DateTime timestamp, out bool isHourType))
- continue;
+ continue;
// Verifica se la chiave fosse scaduta rispetto all'orario corrente
bool isExpired = isHourType
? timestamp < currentHourStart
- : false;
+ : false;
//: timestamp < currentDayStart;
+#endif
// Se fosse scaduta e abbiamo il permesso, segnamola per la cancellazione
- if (isExpired && deleteConfirmed)
+ if (meta.Timestamp < currentHourStart && deleteConfirmed)
{
// 1. Segna la chiave Hash (Dati) per l'eliminazione
keysToDelete.Add(sKey);
@@ -280,17 +287,23 @@ namespace MP.RIOC.Services
if (maxMs >= SentinelValue) maxMs = SentinelValue;
if (count <= 0) continue;
+ // recupero se presente noReply
+ long noReply = 0;
+ if (dict.TryGetValue("noReply", out var sNoReply))
+ {
+ noReply = long.Parse(sNoReply);
+ }
detailRecordsToInsert.Add(new StatsDetailModel
{
- Destination = dest,
- Type = method,
- Hour = timestamp,
+ Destination = meta.Dest,
+ Type = meta.Method,
+ Hour = meta.Timestamp,
RequestCount = count,
AvgDuration = totalMs / count,
MinDuration = minMs,
MaxDuration = maxMs,
- NoReply = 0
+ NoReply = noReply
});
}
}
@@ -322,7 +335,35 @@ namespace MP.RIOC.Services
Log.Info($"[CLEANUP HOUR] Deleted {deletedCount} expired metric keys from Redis");
}
}
+ private bool TryParseKeyMetadata(RedisKey key, out KeyMeta meta)
+ {
+ meta = new KeyMeta();
+ try
+ {
+ string k = key.ToString();
+ string relativeKey = k.Replace($"{_redisBaseKey}:", "");
+ var p = relativeKey.Split(':');
+ if (p.Length < 4) return false;
+ meta.IsHourType = p[1].Equals("hour", StringComparison.InvariantCultureIgnoreCase);
+ meta.Dest = p[2];
+ if (meta.IsHourType)
+ {
+ meta.Method = p[3];
+ if (p.Length >= 5) DateTime.TryParseExact(p[4], "yyyyMMddHH", CultureInfo.InvariantCulture, DateTimeStyles.None, out meta.Timestamp);
+ }
+ else
+ {
+ meta.Method = "DAILY";
+ meta.MachId = p[3];
+ if (p.Length >= 5) DateTime.TryParseExact(p[4], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out meta.Timestamp);
+ }
+ return meta.Timestamp != DateTime.MinValue;
+ }
+ catch { return false; }
+ }
+
+#if false
private bool TryParseKeyMetadata(RedisKey key, out string dest, out string method, out string machId, out DateTime timestamp, out bool isHourType)
{
dest = "NA";
@@ -374,6 +415,16 @@ namespace MP.RIOC.Services
}
catch { }
return false;
+ }
+#endif
+
+ private record KeyMeta
+ {
+ public string Dest = "NA";
+ public string Method = "NA";
+ public string MachId = "ALL";
+ public DateTime Timestamp = DateTime.MinValue;
+ public bool IsHourType = true;
}
#endregion Private Methods
diff --git a/MP.RIOC/Services/RouteManager.cs b/MP.RIOC/Services/RouteManager.cs
index 2dd96b89..e6961d85 100644
--- a/MP.RIOC/Services/RouteManager.cs
+++ b/MP.RIOC/Services/RouteManager.cs
@@ -70,11 +70,9 @@ namespace MP.RIOC.Services
var (oldW, newW) = _weightProvider.GetWeightsFor(metodo);
var pickNew = DecideByWeights(oldW, newW);
var targetLabel = pickNew ? "IOC" : "IO";
+ string sKey = $"{targetLabel}|{metodo}|{id}";
- var destBase = pickNew
- ? _config["ServerConf:NewApiUrl"]
- : _config["ServerConf:OldApiUrl"];
-
+ var destBase = pickNew ? _config["ServerConf:NewApiUrl"] : _config["ServerConf:OldApiUrl"];
if (string.IsNullOrEmpty(destBase))
{
context.Response.StatusCode = 502;
@@ -82,42 +80,57 @@ namespace MP.RIOC.Services
return;
}
+ // Verifica destinazione
+ // per evitare il "doppio slash" (es. .../api/IOB//metodo)
+ if (!destBase.EndsWith("/")) destBase += "/";
+
+ // avvio registrazione statistice
+ _stats.Record(sKey);
+
// 4. PREPARAZIONE FORWARDING
var originalPath = context.Request.Path;
var originalPathBase = context.Request.PathBase;
try
{
- string sKey = $"{targetLabel}|{metodo}|{id}";
- _stats.Record(sKey);
-
- // Assicuriamoci che la destinazione finisca con / e il path inizi senza /
- // per evitare il "doppio slash" (es. .../api/IOB//metodo)
- if (!destBase.EndsWith("/")) destBase += "/";
-
context.Request.Path = new PathString("/" + relativePath);
context.Request.PathBase = PathString.Empty;
- // Esecuzione
+ // ESECUZIONE FORWARDING
var error = await _forwarder.SendAsync(context, destBase, _httpClientInvoker, _forwarderConfig, HttpTransformer.Default, context.RequestAborted);
// commento transformer custom
//var error = await _forwarder.SendAsync(context, destBase, _httpClientInvoker, _forwarderConfig, _transformer, context.RequestAborted);
+
sw.Stop();
_stats.RecordDuration(sKey, sw.Elapsed);
+ // REGISTRAZIONE STATUS CODE (Sempre, se disponibile)
+ _stats.RecordStatusCode(sKey, context.Response.StatusCode);
+
if (error != ForwarderError.None)
{
var feat = context.GetForwarderErrorFeature();
- Log.Error(feat?.Exception, "Forwarder error to {DestBase} for {Method}", destBase, metodo);
+ var errorMsg = feat?.Exception?.Message ?? error.ToString();
+
+ // REGISTRAZIONE ERRORE DETTAGLIATO
+ _stats.RecordError(sKey, errorMsg);
+
+ Log.Error(feat?.Exception, "Forwarder error to {DestBase} for {Method}: {Msg}", destBase, metodo, errorMsg);
if (!context.Response.HasStarted)
{
context.Response.StatusCode = 502;
- await context.Response.WriteAsync($"Forward error: {feat?.Exception?.Message ?? error.ToString()}");
+ await context.Response.WriteAsync($"Forward error: {errorMsg}");
}
}
}
+ catch (Exception ex)
+ {
+ sw.Stop();
+ _stats.RecordError(sKey, ex.Message);
+ Log.Fatal(ex, "Critical error in RouteManager");
+ }
finally
{
context.Request.Path = originalPath;
diff --git a/MP.RIOC/Services/RouteStatsManager.cs b/MP.RIOC/Services/RouteStatsManager.cs
index 9274826a..7bba986c 100644
--- a/MP.RIOC/Services/RouteStatsManager.cs
+++ b/MP.RIOC/Services/RouteStatsManager.cs
@@ -9,6 +9,7 @@ namespace MP.RIOC.Services
public TimeSpan MaxDuration = TimeSpan.Zero;
public TimeSpan MinDuration = TimeSpan.MaxValue;
public ConcurrentDictionary StatusCodes = new();
+ public ConcurrentDictionary ErrorMessages = new();
public TimeSpan AvgDuration => TotalDuration / (Count > 0 ? Count : 1);
}
@@ -51,6 +52,21 @@ namespace MP.RIOC.Services
}
}
+ ///
+ /// Registrazione errore
+ ///
+ ///
+ ///
+ public void RecordError(string dest_method, string errorMessage)
+ {
+ if (_map.TryGetValue(dest_method, out var stat))
+ {
+ // Puliamo il messaggio per evitare chiavi infinite (es. togliamo timestamp o ID dinamici)
+ var cleanMsg = errorMessage.Length > 100 ? errorMessage[..100] + "..." : errorMessage;
+ stat.ErrorMessages.AddOrUpdate(cleanMsg, 1, (_, v) => v + 1);
+ }
+ }
+
public void RecordStatusCode(string method, int statusCode)
{
if (_map.TryGetValue(method, out var stat))