diff --git a/SAMA.Data/Configuration/CheckResultConfiguration.cs b/SAMA.Data/Configuration/CheckResultConfiguration.cs index 4a1c405..aac0ecc 100644 --- a/SAMA.Data/Configuration/CheckResultConfiguration.cs +++ b/SAMA.Data/Configuration/CheckResultConfiguration.cs @@ -31,8 +31,9 @@ public void Configure(EntityTypeBuilder builder) // Indexes builder.HasIndex(cr => new { cr.CheckId, cr.CheckedAt }) - .HasDatabaseName("IX_CheckResults_CheckId_CheckedAt") - .IsDescending(false, true); // CheckId ASC, CheckedAt DESC + .HasDatabaseName("IX_CheckResults_CheckId_CheckedAt_Covering") + .IsDescending(false, true) + .IncludeProperties(cr => new { cr.Status, cr.ResponseTimeMs, cr.ErrorMessage }); builder.HasIndex(cr => cr.CheckedAt) .HasDatabaseName("IX_CheckResults_CheckedAt"); diff --git a/SAMA.Data/Migrations/20260403004123_AddCoveringIndexForCheckResults.Designer.cs b/SAMA.Data/Migrations/20260403004123_AddCoveringIndexForCheckResults.Designer.cs new file mode 100644 index 0000000..efb3d82 --- /dev/null +++ b/SAMA.Data/Migrations/20260403004123_AddCoveringIndexForCheckResults.Designer.cs @@ -0,0 +1,962 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SAMA.Data; + +#nullable disable + +namespace SAMA.Data.Migrations +{ + [DbContext(typeof(SamaDbContext))] + [Migration("20260403004123_AddCoveringIndexForCheckResults")] + partial class AddCoveringIndexForCheckResults + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NotificationChannelMappings", b => + { + b.Property("AlertId") + .HasColumnType("uuid"); + + b.Property("NotificationChannelId") + .HasColumnType("uuid"); + + b.HasKey("AlertId", "NotificationChannelId"); + + b.HasIndex("NotificationChannelId"); + + b.ToTable("NotificationChannelMappings", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Alert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("FailureThreshold") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SendRecoveryNotification") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TriggerOnDown") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TriggerOnWarn") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("CheckId") + .HasDatabaseName("IX_Alerts_CheckId"); + + b.HasIndex("Enabled") + .HasDatabaseName("IX_Alerts_Enabled"); + + b.ToTable("Alerts", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.AlertHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlertId") + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationChannelId") + .HasColumnType("uuid"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("TriggerEventId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NotificationChannelId") + .HasDatabaseName("IX_AlertHistory_NotificationChannelId"); + + b.HasIndex("TriggerEventId") + .HasDatabaseName("IX_AlertHistory_TriggerEventId"); + + b.HasIndex("AlertId", "SentAt") + .IsDescending(false, true) + .HasDatabaseName("IX_AlertHistory_AlertId_SentAt"); + + b.ToTable("AlertHistories", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Changes") + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("IX_AuditLogs_Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_AuditLogs_EntityType_EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Check", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Schedule") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(30); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Enabled") + .HasDatabaseName("IX_Checks_Enabled"); + + b.HasIndex("WorkspaceId") + .HasDatabaseName("IX_Checks_WorkspaceId"); + + b.ToTable("Checks", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.CheckResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ResponseTimeMs") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedAt") + .HasDatabaseName("IX_CheckResults_CheckedAt"); + + b.HasIndex("CheckId", "CheckedAt") + .IsDescending(false, true) + .HasDatabaseName("IX_CheckResults_CheckId_CheckedAt_Covering"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("CheckId", "CheckedAt"), new[] { "Status", "ResponseTimeMs", "ErrorMessage" }); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NotificationChannelId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EventType") + .HasDatabaseName("IX_EventSubscriptions_EventType"); + + b.HasIndex("NotificationChannelId") + .HasDatabaseName("IX_EventSubscriptions_NotificationChannelId"); + + b.HasIndex("NotificationChannelId", "EventType") + .IsUnique() + .HasDatabaseName("UX_EventSubscriptions_Subscription"); + + b.ToTable("EventSubscriptions", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.GlobalSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("GlobalSettings", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.NotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Enabled") + .HasDatabaseName("IX_NotificationChannels_Enabled"); + + b.HasIndex("WorkspaceId") + .HasDatabaseName("IX_NotificationChannels_WorkspaceId"); + + b.ToTable("NotificationChannels", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.UserWorkspace", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("Manual"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.HasKey("UserId", "WorkspaceId"); + + b.HasIndex("Source") + .HasDatabaseName("IX_UserWorkspaces_Source"); + + b.HasIndex("WorkspaceId") + .HasDatabaseName("IX_UserWorkspaces_WorkspaceId"); + + b.ToTable("UserWorkspaces", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("DashboardMessage") + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("IsPublic") + .HasDatabaseName("IX_Workspaces_IsPublic"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("SAMA.Data.Entities.WorkspaceGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalGroupId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IdentityProvider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId") + .HasDatabaseName("IX_WorkspaceGroupMappings_WorkspaceId"); + + b.HasIndex("WorkspaceId", "IdentityProvider", "ExternalGroupId") + .IsUnique() + .HasDatabaseName("UX_WorkspaceGroupMappings_Mapping"); + + b.ToTable("WorkspaceGroupMappings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SAMA.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SAMA.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SAMA.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SAMA.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NotificationChannelMappings", b => + { + b.HasOne("SAMA.Data.Entities.Alert", null) + .WithMany() + .HasForeignKey("AlertId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SAMA.Data.Entities.NotificationChannel", null) + .WithMany() + .HasForeignKey("NotificationChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Alert", b => + { + b.HasOne("SAMA.Data.Entities.Check", "Check") + .WithMany("Alerts") + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Check"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.AlertHistory", b => + { + b.HasOne("SAMA.Data.Entities.Alert", "Alert") + .WithMany("AlertHistories") + .HasForeignKey("AlertId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SAMA.Data.Entities.NotificationChannel", "NotificationChannel") + .WithMany("AlertHistories") + .HasForeignKey("NotificationChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alert"); + + b.Navigation("NotificationChannel"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.AuditLog", b => + { + b.HasOne("SAMA.Data.Entities.ApplicationUser", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Check", b => + { + b.HasOne("SAMA.Data.Entities.Workspace", "Workspace") + .WithMany("Checks") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.CheckResult", b => + { + b.HasOne("SAMA.Data.Entities.Check", "Check") + .WithMany("CheckResults") + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Check"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.EventSubscription", b => + { + b.HasOne("SAMA.Data.Entities.NotificationChannel", "NotificationChannel") + .WithMany("EventSubscriptions") + .HasForeignKey("NotificationChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NotificationChannel"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.NotificationChannel", b => + { + b.HasOne("SAMA.Data.Entities.Workspace", "Workspace") + .WithMany("NotificationChannels") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.UserWorkspace", b => + { + b.HasOne("SAMA.Data.Entities.ApplicationUser", "User") + .WithMany("UserWorkspaces") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SAMA.Data.Entities.Workspace", "Workspace") + .WithMany("UserWorkspaces") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.WorkspaceGroupMapping", b => + { + b.HasOne("SAMA.Data.Entities.Workspace", "Workspace") + .WithMany("WorkspaceGroupMappings") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Alert", b => + { + b.Navigation("AlertHistories"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.ApplicationUser", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("UserWorkspaces"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Check", b => + { + b.Navigation("Alerts"); + + b.Navigation("CheckResults"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.NotificationChannel", b => + { + b.Navigation("AlertHistories"); + + b.Navigation("EventSubscriptions"); + }); + + modelBuilder.Entity("SAMA.Data.Entities.Workspace", b => + { + b.Navigation("Checks"); + + b.Navigation("NotificationChannels"); + + b.Navigation("UserWorkspaces"); + + b.Navigation("WorkspaceGroupMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SAMA.Data/Migrations/20260403004123_AddCoveringIndexForCheckResults.cs b/SAMA.Data/Migrations/20260403004123_AddCoveringIndexForCheckResults.cs new file mode 100644 index 0000000..8b2d67c --- /dev/null +++ b/SAMA.Data/Migrations/20260403004123_AddCoveringIndexForCheckResults.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SAMA.Data.Migrations +{ + /// + public partial class AddCoveringIndexForCheckResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_CheckResults_CheckId_CheckedAt", + table: "CheckResults"); + + migrationBuilder.CreateIndex( + name: "IX_CheckResults_CheckId_CheckedAt_Covering", + table: "CheckResults", + columns: new[] { "CheckId", "CheckedAt" }, + descending: new[] { false, true }) + .Annotation("Npgsql:IndexInclude", new[] { "Status", "ResponseTimeMs", "ErrorMessage" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_CheckResults_CheckId_CheckedAt_Covering", + table: "CheckResults"); + + migrationBuilder.CreateIndex( + name: "IX_CheckResults_CheckId_CheckedAt", + table: "CheckResults", + columns: new[] { "CheckId", "CheckedAt" }, + descending: new[] { false, true }); + } + } +} diff --git a/SAMA.Data/Migrations/SamaDbContextModelSnapshot.cs b/SAMA.Data/Migrations/SamaDbContextModelSnapshot.cs index f5fffd4..a91739c 100644 --- a/SAMA.Data/Migrations/SamaDbContextModelSnapshot.cs +++ b/SAMA.Data/Migrations/SamaDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -495,7 +495,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CheckId", "CheckedAt") .IsDescending(false, true) - .HasDatabaseName("IX_CheckResults_CheckId_CheckedAt"); + .HasDatabaseName("IX_CheckResults_CheckId_CheckedAt_Covering"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("CheckId", "CheckedAt"), new[] { "Status", "ResponseTimeMs", "ErrorMessage" }); b.ToTable("CheckResults", (string)null); }); diff --git a/SAMA.Data/SAMA.Data.csproj b/SAMA.Data/SAMA.Data.csproj index b08b3e1..5e8d97e 100644 --- a/SAMA.Data/SAMA.Data.csproj +++ b/SAMA.Data/SAMA.Data.csproj @@ -7,13 +7,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SAMA.Shared/SAMA.Shared.csproj b/SAMA.Shared/SAMA.Shared.csproj index bf448ae..933ddf7 100644 --- a/SAMA.Shared/SAMA.Shared.csproj +++ b/SAMA.Shared/SAMA.Shared.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj b/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj index 0bcdb4e..d950d81 100644 --- a/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj +++ b/SAMA.Tests.Integration/SAMA.Tests.Integration.csproj @@ -13,10 +13,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + all diff --git a/SAMA.Tests.Integration/Web/Services/Queries/WorkspaceQueryServiceTests.cs b/SAMA.Tests.Integration/Web/Services/Queries/WorkspaceQueryServiceTests.cs index f90b7bd..ebd4923 100644 --- a/SAMA.Tests.Integration/Web/Services/Queries/WorkspaceQueryServiceTests.cs +++ b/SAMA.Tests.Integration/Web/Services/Queries/WorkspaceQueryServiceTests.cs @@ -1,4 +1,7 @@ +using NSubstitute; using SAMA.Data.Entities; +using SAMA.Shared.Constants; +using SAMA.Web.Services; using SAMA.Web.Services.Queries; namespace SAMA.Tests.Integration.Web.Services.Queries; @@ -7,13 +10,19 @@ namespace SAMA.Tests.Integration.Web.Services.Queries; public class WorkspaceQueryServiceTests : IntegrationTestBase { private WorkspaceQueryService _service = null!; + private ApplicationStateService _mockAppState = null!; + private DateTimeOffset _testStartTime; [TestInitialize] public override async Task InitializeTestAsync() { await base.InitializeTestAsync(); - _service = new WorkspaceQueryService(DbContext); + _testStartTime = DateTimeOffset.UtcNow; + _mockAppState = Substitute.For(); + _mockAppState.StartupTime.Returns(_testStartTime.AddMinutes(-10)); + + _service = new WorkspaceQueryService(DbContext, _mockAppState); } [TestMethod] @@ -183,6 +192,135 @@ public async Task GetWorkspacesAsyncShouldFilterByWorkspaceIds() Assert.IsFalse(result.Any(w => w.Id == workspace2.Id)); } + [TestMethod] + public async Task GetWorkspacesAsyncShouldCountStatusesCorrectly() + { + var workspace = await CreateWorkspaceAsync("Test Workspace", null, false); + var upCheck = await CreateCheckAsync(workspace.Id, "Up Check"); + var warnCheck = await CreateCheckAsync(workspace.Id, "Warn Check"); + var downCheck = await CreateCheckAsync(workspace.Id, "Down Check"); + + await CreateCheckResultAsync(upCheck.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + await CreateCheckResultAsync(warnCheck.Id, CheckStatuses.Warn, _testStartTime.AddMinutes(-5)); + await CreateCheckResultAsync(downCheck.Id, CheckStatuses.Down, _testStartTime.AddMinutes(-5)); + + var result = await _service.GetWorkspacesAsync(); + + var item = result.Single(); + Assert.AreEqual(3, item.CheckCount); + Assert.AreEqual(1, item.UpCount); + Assert.AreEqual(1, item.WarnCount); + Assert.AreEqual(1, item.DownCount); + } + + [TestMethod] + public async Task GetWorkspacesAsyncShouldNotCountDisabledChecksInStatusCounts() + { + var workspace = await CreateWorkspaceAsync("Test Workspace", null, false); + var enabledCheck = await CreateCheckAsync(workspace.Id, "Enabled Check"); + var disabledCheck = await CreateCheckAsync(workspace.Id, "Disabled Check", enabled: false); + + await CreateCheckResultAsync(enabledCheck.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + await CreateCheckResultAsync(disabledCheck.Id, CheckStatuses.Down, _testStartTime.AddMinutes(-5)); + + var result = await _service.GetWorkspacesAsync(); + + var item = result.Single(); + Assert.AreEqual(2, item.CheckCount); + Assert.AreEqual(1, item.UpCount); + Assert.AreEqual(0, item.DownCount); + } + + [TestMethod] + public async Task GetWorkspacesAsyncShouldTreatResultsBeforeStartupAsPending() + { + var workspace = await CreateWorkspaceAsync("Test Workspace", null, false); + var check = await CreateCheckAsync(workspace.Id, "Old Check"); + + await CreateCheckResultAsync(check.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-20)); + + var result = await _service.GetWorkspacesAsync(); + + var item = result.Single(); + Assert.AreEqual(1, item.CheckCount); + Assert.AreEqual(0, item.UpCount); + Assert.AreEqual(0, item.WarnCount); + Assert.AreEqual(0, item.DownCount); + } + + [TestMethod] + public async Task GetWorkspacesAsyncShouldTreatUpdatedCheckAsPending() + { + var workspace = await CreateWorkspaceAsync("Test Workspace", null, false); + var check = await CreateCheckAsync(workspace.Id, "Updated Check"); + + await CreateCheckResultAsync(check.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + + check.UpdatedAt = _testStartTime.AddMinutes(-3); + DbContext.Checks.Update(check); + await DbContext.SaveChangesAsync(); + DbContext.ChangeTracker.Clear(); + + var result = await _service.GetWorkspacesAsync(); + + var item = result.Single(); + Assert.AreEqual(0, item.UpCount); + } + + [TestMethod] + public async Task GetWorkspaceDetailsAsyncShouldCountStatusesCorrectly() + { + var workspace = await CreateWorkspaceAsync("Test Workspace", null, false); + var upCheck1 = await CreateCheckAsync(workspace.Id, "Up 1"); + var upCheck2 = await CreateCheckAsync(workspace.Id, "Up 2"); + var downCheck = await CreateCheckAsync(workspace.Id, "Down"); + + await CreateCheckResultAsync(upCheck1.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + await CreateCheckResultAsync(upCheck2.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + await CreateCheckResultAsync(downCheck.Id, CheckStatuses.Down, _testStartTime.AddMinutes(-5)); + + var result = await _service.GetWorkspaceDetailsAsync(workspace.Id); + + Assert.IsNotNull(result); + Assert.AreEqual(2, result.UpCount); + Assert.AreEqual(0, result.WarnCount); + Assert.AreEqual(1, result.DownCount); + } + + [TestMethod] + public async Task GetWorkspaceDetailsAsyncShouldUseLatestResultOnly() + { + var workspace = await CreateWorkspaceAsync("Test Workspace", null, false); + var check = await CreateCheckAsync(workspace.Id, "Check"); + + await CreateCheckResultAsync(check.Id, CheckStatuses.Down, _testStartTime.AddMinutes(-8)); + await CreateCheckResultAsync(check.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + + var result = await _service.GetWorkspaceDetailsAsync(workspace.Id); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.UpCount); + Assert.AreEqual(0, result.DownCount); + } + + [TestMethod] + public async Task GetWorkspacesAsyncShouldNotCountChecksFromOtherWorkspaces() + { + var workspace1 = await CreateWorkspaceAsync("Workspace 1", null, false); + var workspace2 = await CreateWorkspaceAsync("Workspace 2", null, false); + var check1 = await CreateCheckAsync(workspace1.Id, "Check 1"); + var check2 = await CreateCheckAsync(workspace2.Id, "Check 2"); + + await CreateCheckResultAsync(check1.Id, CheckStatuses.Up, _testStartTime.AddMinutes(-5)); + await CreateCheckResultAsync(check2.Id, CheckStatuses.Down, _testStartTime.AddMinutes(-5)); + + var result = await _service.GetWorkspacesAsync([workspace1.Id]); + + var item = result.Single(); + Assert.AreEqual(1, item.UpCount); + Assert.AreEqual(0, item.DownCount); + } + // ...existing helper methods... private async Task CreateWorkspaceAsync(string name, string? description, bool isPublic) { @@ -202,7 +340,7 @@ private async Task CreateWorkspaceAsync(string name, string? descript return workspace; } - private async Task CreateCheckAsync(Guid workspaceId, string name) + private async Task CreateCheckAsync(Guid workspaceId, string name, bool enabled = true) { var check = new Check { @@ -212,9 +350,9 @@ private async Task CreateCheckAsync(Guid workspaceId, string name) ConfigurationJson = [], Schedule = "60", TimeoutSeconds = 30, - Enabled = true, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow + Enabled = enabled, + CreatedAt = _testStartTime.AddHours(-1), + UpdatedAt = _testStartTime.AddHours(-1) }; DbContext.Checks.Add(check); @@ -243,4 +381,23 @@ private async Task CreateNotificationChannelAsync(Guid work return channel; } + + private async Task CreateCheckResultAsync( + Guid checkId, + string status, + DateTimeOffset checkedAt) + { + var result = new CheckResult + { + CheckId = checkId, + Status = status, + CheckedAt = checkedAt + }; + + DbContext.CheckResults.Add(result); + await DbContext.SaveChangesAsync(); + DbContext.ChangeTracker.Clear(); + + return result; + } } diff --git a/SAMA.Tests.Unit/Web/Pages/Admin/Settings/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Admin/Settings/IndexModelTests.cs index f2c7237..fa0c438 100644 --- a/SAMA.Tests.Unit/Web/Pages/Admin/Settings/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Admin/Settings/IndexModelTests.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; using NSubstitute; -using SAMA.Data; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Pages.Admin.Settings; using SAMA.Web.Services; @@ -22,7 +21,7 @@ public class IndexModelTests public void Setup() { _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockLogger = Substitute.For>(); _mockWorkspaceQuery.GetWorkspacesAsync(Arg.Any?>(), Arg.Any()) diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs index 6963bd8..85a5b27 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -26,7 +25,7 @@ public void Setup() _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockChannelQuery = Substitute.For(null!, null!); _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new CreateModel(_mockWorkspaceQuery, _mockChannelQuery, _mockCheckQuery, _mockAlertCommand); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs index 6eae740..7e81c6c 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs @@ -24,7 +24,7 @@ public void Setup() { _mockAlertQuery = Substitute.For((SamaDbContext)null!); _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new DeleteModel(_mockWorkspaceQuery, _mockAlertQuery, _mockAlertCommand); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/DetailsModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/DetailsModelTests.cs index 97420be..6829285 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/DetailsModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/DetailsModelTests.cs @@ -21,7 +21,7 @@ public class DetailsModelTests public void Setup() { _mockAlertQuery = Substitute.For((SamaDbContext)null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new DetailsModel(_mockWorkspaceQuery, _mockAlertQuery); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs index cc32e33..d72f44d 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs @@ -28,7 +28,7 @@ public void Setup() _mockChannelQuery = Substitute.For(null!, null!); _mockAlertQuery = Substitute.For((SamaDbContext)null!); _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new EditModel(_mockWorkspaceQuery, _mockChannelQuery, _mockCheckQuery, _mockAlertQuery, _mockAlertCommand); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/IndexModelTests.cs index 5440b46..f943c2c 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/IndexModelTests.cs @@ -23,7 +23,7 @@ public void Setup() { _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockAlertQuery = Substitute.For((SamaDbContext)null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new IndexModel(_mockWorkspaceQuery, _mockCheckQuery, _mockAlertQuery); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs index a405c47..77e4078 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Shared.Constants; using SAMA.Tests.Unit.TestUtilities; @@ -24,7 +23,7 @@ public class CreateModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockCheckConfigService = Substitute.For(); _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!); _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs index 3ac4e3e..7167a29 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -24,7 +23,7 @@ public void Setup() { _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!); - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new DeleteModel(_mockWorkspaceQuery, _mockCheckQuery, _mockCheckCommand); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/DetailsModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/DetailsModelTests.cs index d50625b..8fa394e 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/DetailsModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/DetailsModelTests.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -24,7 +23,7 @@ public class DetailsModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockScriptOutputBuffer = new ScriptOutputBuffer(Substitute.For>()); _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs index 094c34e..16972da 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Shared.Constants; using SAMA.Tests.Unit.TestUtilities; @@ -25,7 +24,7 @@ public class EditModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockConfigService = Substitute.For(); _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/IndexModelTests.cs index aec9dfe..449e64f 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/IndexModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -22,7 +21,7 @@ public class IndexModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs index d13b9b7..177c28a 100644 --- a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs @@ -24,7 +24,7 @@ public class IndexModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockAlertQuery = Substitute.For((SamaDbContext)null!); _mockGlobalSettings = Substitute.For(null, null, null, null); diff --git a/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/IndexModelTests.cs index 19d62d9..25a96ee 100644 --- a/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/IndexModelTests.cs @@ -21,7 +21,7 @@ public class IndexModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockEventSubscriptionQuery = Substitute.For((SamaDbContext)null!); _pageModel = new IndexModel(_mockWorkspaceQuery, _mockEventSubscriptionQuery); diff --git a/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/ManageModelTests.cs b/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/ManageModelTests.cs index 2bbd94c..f000119 100644 --- a/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/ManageModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/EventSubscriptions/ManageModelTests.cs @@ -24,7 +24,7 @@ public class ManageModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockEventSubscriptionQuery = Substitute.For((SamaDbContext)null!); _mockEventSubscriptionCommand = Substitute.For(null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/CreateModelTests.cs b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/CreateModelTests.cs index c2c97c5..5518565 100644 --- a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/CreateModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/CreateModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -23,7 +22,7 @@ public class CreateModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockConfigService = Substitute.For(); _mockChannelCommand = Substitute.For(null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DeleteModelTests.cs index c85c6cc..b6bd985 100644 --- a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DeleteModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -22,7 +21,7 @@ public class DeleteModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockChannelQuery = Substitute.For(null!, null!); _mockChannelCommand = Substitute.For(null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DetailsModelTests.cs b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DetailsModelTests.cs index 613d5cb..8159a65 100644 --- a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DetailsModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/DetailsModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -20,7 +19,7 @@ public class DetailsModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockChannelQuery = Substitute.For(null!, null!); _pageModel = new DetailsModel(_mockWorkspaceQuery, _mockChannelQuery); diff --git a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/EditModelTests.cs index 8f5d2e4..d4e4a14 100644 --- a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/EditModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Shared.Constants; using SAMA.Tests.Unit.TestUtilities; @@ -25,7 +24,7 @@ public class EditModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockChannelQuery = Substitute.For(null!, null!); _mockConfigService = Substitute.For(); _mockChannelCommand = Substitute.For(null!, null!); diff --git a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/IndexModelTests.cs index 34922c7..6a21ac5 100644 --- a/SAMA.Tests.Unit/Web/Pages/NotificationChannels/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/NotificationChannels/IndexModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -20,7 +19,7 @@ public class IndexModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockChannelQuery = Substitute.For(null!, null!); _pageModel = new IndexModel(_mockWorkspaceQuery, _mockChannelQuery); diff --git a/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs index bda0da3..f395c75 100644 --- a/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; @@ -21,7 +20,7 @@ public class DeleteModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockWorkspaceCommand = Substitute.For(null!, null!); _pageModel = new DeleteModel(_mockWorkspaceQuery, _mockWorkspaceCommand); diff --git a/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs index f41a781..b90288d 100644 --- a/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Pages.Workspaces; @@ -22,7 +21,7 @@ public class EditModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockWorkspaceCommand = Substitute.For(null!, null!); _markdownService = new MarkdownService(); diff --git a/SAMA.Tests.Unit/Web/Pages/Workspaces/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Workspaces/IndexModelTests.cs index 3efb47b..d5eb0a0 100644 --- a/SAMA.Tests.Unit/Web/Pages/Workspaces/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Workspaces/IndexModelTests.cs @@ -1,5 +1,4 @@ using NSubstitute; -using SAMA.Data; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; using SAMA.Web.Services; @@ -17,7 +16,7 @@ public class IndexModelTests [TestInitialize] public void Setup() { - _mockWorkspaceQuery = Substitute.For((SamaDbContext)null!); + _mockWorkspaceQuery = Substitute.For(null!, null!); _mockAuthService = Substitute.For(null!, null!); _pageModel = new IndexModel(_mockWorkspaceQuery, _mockAuthService); diff --git a/SAMA.Web/Pages/Shared/WorkspacePageModel.cs b/SAMA.Web/Pages/Shared/WorkspacePageModel.cs index d60d8ed..9690717 100644 --- a/SAMA.Web/Pages/Shared/WorkspacePageModel.cs +++ b/SAMA.Web/Pages/Shared/WorkspacePageModel.cs @@ -19,9 +19,9 @@ public abstract class WorkspacePageModel : PageModel private readonly WorkspaceQueryService _workspaceQueryService; - public WorkspacePageModel(SamaDbContext dbContext) + public WorkspacePageModel(SamaDbContext dbContext, ApplicationStateService appStateService) { - _workspaceQueryService = new WorkspaceQueryService(dbContext); + _workspaceQueryService = new WorkspaceQueryService(dbContext, appStateService); } public WorkspacePageModel(WorkspaceQueryService workspaceQueryService) diff --git a/SAMA.Web/SAMA.Web.csproj b/SAMA.Web/SAMA.Web.csproj index 5d7fd29..49431b8 100644 --- a/SAMA.Web/SAMA.Web.csproj +++ b/SAMA.Web/SAMA.Web.csproj @@ -17,10 +17,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/SAMA.Web/Services/Queries/AlertQueryService.cs b/SAMA.Web/Services/Queries/AlertQueryService.cs index 9fcc6d7..80c5be2 100644 --- a/SAMA.Web/Services/Queries/AlertQueryService.cs +++ b/SAMA.Web/Services/Queries/AlertQueryService.cs @@ -38,7 +38,6 @@ public virtual async Task> GetAlertsForCheckAsync( .Include(a => a.Check) .ThenInclude(c => c.Workspace) .Include(a => a.NotificationChannels) - .Include(a => a.AlertHistories) .FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken); if (alert == null) @@ -46,6 +45,9 @@ public virtual async Task> GetAlertsForCheckAsync( return null; } + var alertHistoryCount = await _dbContext.AlertHistories + .CountAsync(ah => ah.AlertId == alertId, cancellationToken); + return new AlertDetailsViewModel { Id = alert.Id, @@ -70,7 +72,7 @@ public virtual async Task> GetAlertsForCheckAsync( Enabled = nc.Enabled }) .ToList(), - AlertHistoryCount = alert.AlertHistories.Count + AlertHistoryCount = alertHistoryCount }; } diff --git a/SAMA.Web/Services/Queries/CheckQueryService.cs b/SAMA.Web/Services/Queries/CheckQueryService.cs index c5fa5c4..e8cb133 100644 --- a/SAMA.Web/Services/Queries/CheckQueryService.cs +++ b/SAMA.Web/Services/Queries/CheckQueryService.cs @@ -17,9 +17,7 @@ public virtual async Task> GetChecksForWorkspaceAsy var startupTime = _appStateService.StartupTime; var checks = await _samaDbContext.Checks - .AsSplitQuery() - .Include(c => c.CheckResults) - .Include(c => c.Alerts) + .AsNoTracking() .Where(c => c.WorkspaceId == workspaceId) .Select(c => new CheckListItemViewModel { @@ -30,26 +28,44 @@ public virtual async Task> GetChecksForWorkspaceAsy Schedule = c.Schedule, CreatedAt = c.CreatedAt, UpdatedAt = c.UpdatedAt, - LastStatus = c.CheckResults - .OrderByDescending(cr => cr.CheckedAt) - .Select(cr => cr.Status) - .FirstOrDefault(), - LastCheckedAt = c.CheckResults - .OrderByDescending(cr => cr.CheckedAt) - .Select(cr => (DateTimeOffset?)cr.CheckedAt) - .FirstOrDefault(), - LastResponseTimeMs = c.CheckResults - .OrderByDescending(cr => cr.CheckedAt) - .Select(cr => cr.ResponseTimeMs) - .FirstOrDefault(), - LastErrorMessage = c.CheckResults - .OrderByDescending(cr => cr.CheckedAt) - .Select(cr => cr.ErrorMessage) - .FirstOrDefault(), AlertCount = c.Alerts.Count }) .ToListAsync(cancellationToken); + if (checks.Count > 0) + { + var checkIds = checks.Select(c => c.Id).ToList(); + var latestResults = await _samaDbContext.Checks + .AsNoTracking() + .Where(c => checkIds.Contains(c.Id)) + .Select(c => c.CheckResults + .OrderByDescending(cr => cr.CheckedAt) + .Select(cr => new + { + cr.CheckId, + cr.Status, + cr.CheckedAt, + cr.ResponseTimeMs, + cr.ErrorMessage, + }) + .FirstOrDefault()) + .Where(r => r != null) + .Select(r => r!) + .ToListAsync(cancellationToken); + + var resultsByCheckId = latestResults.ToDictionary(r => r.CheckId); + foreach (var check in checks) + { + if (resultsByCheckId.TryGetValue(check.Id, out var result)) + { + check.LastStatus = result.Status; + check.LastCheckedAt = result.CheckedAt; + check.LastResponseTimeMs = result.ResponseTimeMs; + check.LastErrorMessage = result.ErrorMessage; + } + } + } + foreach (var check in checks) { if (!check.Enabled || diff --git a/SAMA.Web/Services/Queries/WorkspaceQueryService.cs b/SAMA.Web/Services/Queries/WorkspaceQueryService.cs index 4b1428c..f0b460c 100644 --- a/SAMA.Web/Services/Queries/WorkspaceQueryService.cs +++ b/SAMA.Web/Services/Queries/WorkspaceQueryService.cs @@ -6,7 +6,7 @@ namespace SAMA.Web.Services.Queries; -public class WorkspaceQueryService(SamaDbContext _dbContext) +public class WorkspaceQueryService(SamaDbContext _dbContext, ApplicationStateService _appStateService) { public virtual async Task GetWorkspaceByIdAsync(Guid workspaceId) { @@ -26,7 +26,8 @@ public virtual async Task> GetWorkspacesAsync( query = query.Where(w => workspaceIds.Contains(w.Id)); } - return await query + var workspaces = await query + .AsNoTracking() .OrderBy(w => w.Name) .Select(w => new WorkspaceDetailsViewModel { @@ -38,23 +39,21 @@ public virtual async Task> GetWorkspacesAsync( CreatedAt = w.CreatedAt, UpdatedAt = w.UpdatedAt, CheckCount = w.Checks.Count, - UpCount = w.Checks.Count(c => c.Enabled && c.CheckResults - .OrderByDescending(r => r.CheckedAt).Select(r => r.Status).FirstOrDefault() == CheckStatuses.Up), - WarnCount = w.Checks.Count(c => c.Enabled && c.CheckResults - .OrderByDescending(r => r.CheckedAt).Select(r => r.Status).FirstOrDefault() == CheckStatuses.Warn), - DownCount = w.Checks.Count(c => c.Enabled && c.CheckResults - .OrderByDescending(r => r.CheckedAt).Select(r => r.Status).FirstOrDefault() == CheckStatuses.Down), NotificationChannelCount = w.NotificationChannels.Count, UserCount = w.UserWorkspaces.Count }) .ToListAsync(cancellationToken); + + await PopulateStatusCounts(workspaces, cancellationToken); + return workspaces; } public virtual async Task GetWorkspaceDetailsAsync( Guid workspaceId, CancellationToken cancellationToken = default) { - return await _dbContext.Workspaces + var workspace = await _dbContext.Workspaces + .AsNoTracking() .Where(w => w.Id == workspaceId) .Select(w => new WorkspaceDetailsViewModel { @@ -66,15 +65,70 @@ public virtual async Task> GetWorkspacesAsync( CreatedAt = w.CreatedAt, UpdatedAt = w.UpdatedAt, CheckCount = w.Checks.Count, - UpCount = w.Checks.Count(c => c.Enabled && c.CheckResults - .OrderByDescending(r => r.CheckedAt).Select(r => r.Status).FirstOrDefault() == CheckStatuses.Up), - WarnCount = w.Checks.Count(c => c.Enabled && c.CheckResults - .OrderByDescending(r => r.CheckedAt).Select(r => r.Status).FirstOrDefault() == CheckStatuses.Warn), - DownCount = w.Checks.Count(c => c.Enabled && c.CheckResults - .OrderByDescending(r => r.CheckedAt).Select(r => r.Status).FirstOrDefault() == CheckStatuses.Down), NotificationChannelCount = w.NotificationChannels.Count, UserCount = w.UserWorkspaces.Count }) .FirstOrDefaultAsync(cancellationToken); + + if (workspace != null) + { + await PopulateStatusCounts([workspace], cancellationToken); + } + return workspace; + } + + private async Task PopulateStatusCounts( + List workspaces, + CancellationToken cancellationToken) + { + if (workspaces.Count == 0) + { + return; + } + + var startupTime = _appStateService.StartupTime; + var wsIds = workspaces.Select(w => w.Id).ToList(); + + var checkStatuses = await _dbContext.Checks + .AsNoTracking() + .Where(c => wsIds.Contains(c.WorkspaceId) && c.Enabled) + .Select(c => new + { + c.WorkspaceId, + c.UpdatedAt, + LatestResult = c.CheckResults + .OrderByDescending(cr => cr.CheckedAt) + .Select(cr => new { cr.Status, CheckedAt = (DateTimeOffset?)cr.CheckedAt }) + .FirstOrDefault() + }) + .ToListAsync(cancellationToken); + + foreach (var ws in workspaces) + { + foreach (var check in checkStatuses.Where(c => c.WorkspaceId == ws.Id)) + { + var status = check.LatestResult?.Status; + var lastCheckedAt = check.LatestResult?.CheckedAt; + if (!lastCheckedAt.HasValue || + lastCheckedAt.Value < startupTime || + check.UpdatedAt > lastCheckedAt.Value) + { + status = null; + } + + switch (status) + { + case CheckStatuses.Up: + ws.UpCount++; + break; + case CheckStatuses.Warn: + ws.WarnCount++; + break; + case CheckStatuses.Down: + ws.DownCount++; + break; + } + } + } } }