diff --git a/SAMA.Data/SAMA.Data.csproj b/SAMA.Data/SAMA.Data.csproj index 5e8d97e..c19805b 100644 --- a/SAMA.Data/SAMA.Data.csproj +++ b/SAMA.Data/SAMA.Data.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + 1.0.0 diff --git a/SAMA.Shared/SAMA.Shared.csproj b/SAMA.Shared/SAMA.Shared.csproj index 933ddf7..e9b9dad 100644 --- a/SAMA.Shared/SAMA.Shared.csproj +++ b/SAMA.Shared/SAMA.Shared.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + 1.0.0 diff --git a/SAMA.Tests.Integration/Web/Services/Commands/AlertCommandServiceTests.cs b/SAMA.Tests.Integration/Web/Services/Commands/AlertCommandServiceTests.cs index 87b1bc5..8e222c8 100644 --- a/SAMA.Tests.Integration/Web/Services/Commands/AlertCommandServiceTests.cs +++ b/SAMA.Tests.Integration/Web/Services/Commands/AlertCommandServiceTests.cs @@ -29,13 +29,12 @@ public override async Task InitializeTestAsync() _mockScheduler = Substitute.For(null, null, null); _mockEventService = Substitute.For(null, null, null); - var alertChangeDetectionService = new AlertChangeDetectionService(); - _service = new AlertCommandService( DbContext, _mockScheduler, _mockEventService, - alertChangeDetectionService, + new AlertChangeDetectionService(), + new DashboardCacheService(ServiceProvider), Substitute.For>()); } diff --git a/SAMA.Tests.Integration/Web/Services/Commands/CheckCommandServiceTests.cs b/SAMA.Tests.Integration/Web/Services/Commands/CheckCommandServiceTests.cs index a9b74a9..1631d84 100644 --- a/SAMA.Tests.Integration/Web/Services/Commands/CheckCommandServiceTests.cs +++ b/SAMA.Tests.Integration/Web/Services/Commands/CheckCommandServiceTests.cs @@ -26,13 +26,12 @@ public override async Task InitializeTestAsync() _mockScheduler = Substitute.For(null, null, null); _mockEventService = Substitute.For(null, null, null); - var changeDetectionService = new CheckChangeDetectionService(); - _service = new CheckCommandService( DbContext, _mockScheduler, _mockEventService, - changeDetectionService, + new CheckChangeDetectionService(), + new DashboardCacheService(ServiceProvider), Substitute.For>()); } diff --git a/SAMA.Tests.Integration/Web/Services/Commands/WorkspaceCommandServiceTests.cs b/SAMA.Tests.Integration/Web/Services/Commands/WorkspaceCommandServiceTests.cs index e93ccc0..c9fde1f 100644 --- a/SAMA.Tests.Integration/Web/Services/Commands/WorkspaceCommandServiceTests.cs +++ b/SAMA.Tests.Integration/Web/Services/Commands/WorkspaceCommandServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using SAMA.Data.Entities; +using SAMA.Web.Services; using SAMA.Web.Services.Commands; namespace SAMA.Tests.Integration.Web.Services.Commands; @@ -16,7 +17,7 @@ public override async Task InitializeTestAsync() { await base.InitializeTestAsync(); - _service = new WorkspaceCommandService(DbContext, Substitute.For>()); + _service = new WorkspaceCommandService(DbContext, new DashboardCacheService(ServiceProvider), Substitute.For>()); } [TestMethod] diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs index 85a5b27..60238b9 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/CreateModelTests.cs @@ -24,7 +24,7 @@ public void Setup() { _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockChannelQuery = Substitute.For(null!, null!); - _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!); + _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!, null!); _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new CreateModel(_mockWorkspaceQuery, _mockChannelQuery, _mockCheckQuery, _mockAlertCommand); diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs index 7e81c6c..9b33f0b 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/DeleteModelTests.cs @@ -23,7 +23,7 @@ public class DeleteModelTests public void Setup() { _mockAlertQuery = Substitute.For((SamaDbContext)null!); - _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!); + _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!, null!); _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new DeleteModel(_mockWorkspaceQuery, _mockAlertQuery, _mockAlertCommand); diff --git a/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs index d72f44d..e74723d 100644 --- a/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Alerts/EditModelTests.cs @@ -27,7 +27,7 @@ public void Setup() _mockCheckQuery = Substitute.For(null!, null!, null!, null!); _mockChannelQuery = Substitute.For(null!, null!); _mockAlertQuery = Substitute.For((SamaDbContext)null!); - _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!); + _mockAlertCommand = Substitute.For(null!, null!, null!, null!, null!, null!); _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new EditModel(_mockWorkspaceQuery, _mockChannelQuery, _mockCheckQuery, _mockAlertQuery, _mockAlertCommand); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs index 77e4078..ba26165 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/CreateModelTests.cs @@ -25,7 +25,7 @@ public void Setup() { _mockWorkspaceQuery = Substitute.For(null!, null!); _mockCheckConfigService = Substitute.For(); - _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!); + _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!, null!); _mockGlobalSettings = Substitute.For(null!, null!, null!, null!); _pageModel = new CreateModel(_mockWorkspaceQuery, _mockCheckConfigService, _mockCheckCommand, _mockGlobalSettings); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs index 7167a29..5a7127d 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/DeleteModelTests.cs @@ -22,7 +22,7 @@ public class DeleteModelTests public void Setup() { _mockCheckQuery = Substitute.For(null!, null!, null!, null!); - _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!); + _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!, null!); _mockWorkspaceQuery = Substitute.For(null!, null!); _pageModel = new DeleteModel(_mockWorkspaceQuery, _mockCheckQuery, _mockCheckCommand); diff --git a/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs index 16972da..6df93ea 100644 --- a/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Checks/EditModelTests.cs @@ -27,7 +27,7 @@ public void Setup() _mockWorkspaceQuery = Substitute.For(null!, null!); _mockConfigService = Substitute.For(); _mockCheckQuery = Substitute.For(null!, null!, null!, null!); - _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!); + _mockCheckCommand = Substitute.For(null!, null!, null!, null!, null!, null!); _pageModel = new EditModel(_mockWorkspaceQuery, _mockCheckQuery, _mockConfigService, _mockCheckCommand); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs index 177c28a..311df14 100644 --- a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs @@ -1,13 +1,14 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.DependencyInjection; using NSubstitute; -using SAMA.Data; using SAMA.Data.Entities; using SAMA.Tests.Unit.TestUtilities; using SAMA.Web.Models; using SAMA.Web.Pages.Dashboard; using SAMA.Web.Services; using SAMA.Web.Services.Queries; +using static SAMA.Web.Services.DashboardCacheService; namespace SAMA.Tests.Unit.Web.Pages.Dashboard; @@ -15,23 +16,34 @@ namespace SAMA.Tests.Unit.Web.Pages.Dashboard; public class IndexModelTests { private WorkspaceQueryService _mockWorkspaceQuery = null!; - private CheckQueryService _mockCheckQuery = null!; - private AlertQueryService _mockAlertQuery = null!; private GlobalSettingsService _mockGlobalSettings = null!; private MarkdownService _markdownService = null!; + private DashboardCacheService _cacheService = null!; private IndexModel _pageModel = null!; [TestInitialize] public void Setup() { _mockWorkspaceQuery = Substitute.For(null!, null!); - _mockCheckQuery = Substitute.For(null!, null!, null!, null!); - _mockAlertQuery = Substitute.For((SamaDbContext)null!); _mockGlobalSettings = Substitute.For(null, null, null, null); _markdownService = new MarkdownService(); + _cacheService = new DashboardCacheService(new ServiceCollection().BuildServiceProvider()); - _pageModel = new IndexModel(_mockWorkspaceQuery, _mockCheckQuery, _mockAlertQuery, _mockGlobalSettings, _markdownService); + _pageModel = new IndexModel(_mockWorkspaceQuery, _mockGlobalSettings, _markdownService, _cacheService); PageModelTestHelpers.ConfigurePageModel(_pageModel); + + _mockWorkspaceQuery.GetWorkspaceDetailsAsync(Arg.Any(), Arg.Any()) + .Returns(new WorkspaceDetailsViewModel()); + } + + private void PrePopulateCache(Guid workspaceId, List? checks = null, List? alerts = null) + { + _cacheService.SetWorkspaceData(workspaceId, new WorkspaceDashboardData( + checks ?? [], + alerts ?? [] + )); + _cacheService.SetTimeline(workspaceId, 24, new WorkspaceIncidentTimelineViewModel()); + _cacheService.SetTrends(workspaceId, 24, new WorkspaceResponseTimeTrendsViewModel()); } [TestMethod] @@ -50,16 +62,9 @@ public async Task OnGetAsyncShouldReturnPageWhenWorkspaceExists() { var workspaceId = Guid.NewGuid(); var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; - var checks = new List(); - var alerts = new List(); - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); + PrePopulateCache(workspaceId); var result = await _pageModel.OnGetAsync(workspaceId, null, null); @@ -71,16 +76,9 @@ public async Task OnGetAsyncShouldPopulateRefreshIntervalFromGlobalSettings() { var workspaceId = Guid.NewGuid(); var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; - var checks = new List(); - var alerts = new List(); - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(10); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); + PrePopulateCache(workspaceId); await _pageModel.OnGetAsync(workspaceId, null, null); @@ -121,15 +119,9 @@ public async Task OnGetAsyncShouldPopulateChecksList() AlertCount = 0 } }; - var alerts = new List(); - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); + PrePopulateCache(workspaceId, checks: checks); await _pageModel.OnGetAsync(workspaceId, null, null); @@ -143,7 +135,6 @@ public async Task OnGetAsyncShouldPopulateRecentAlertsList() { var workspaceId = Guid.NewGuid(); var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; - var checks = new List(); var alerts = new List { new() @@ -165,14 +156,9 @@ public async Task OnGetAsyncShouldPopulateRecentAlertsList() SentAt = DateTimeOffset.UtcNow.AddMinutes(-10) } }; - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); + PrePopulateCache(workspaceId, alerts: alerts); await _pageModel.OnGetAsync(workspaceId, null, null); @@ -186,16 +172,9 @@ public async Task OnGetAsyncShouldPopulateEmptyListsWhenNoData() { var workspaceId = Guid.NewGuid(); var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; - var checks = new List(); - var alerts = new List(); - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); + PrePopulateCache(workspaceId); await _pageModel.OnGetAsync(workspaceId, null, null); @@ -203,75 +182,14 @@ public async Task OnGetAsyncShouldPopulateEmptyListsWhenNoData() Assert.IsEmpty(_pageModel.RecentAlerts); } - [TestMethod] - public async Task OnGetAsyncShouldCallGetChecksForWorkspaceAsyncWithCorrectId() - { - var workspaceId = Guid.NewGuid(); - var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; - var checks = new List(); - var alerts = new List(); - - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); - _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); - - await _pageModel.OnGetAsync(workspaceId, null, null); - - await _mockCheckQuery.Received(1).GetChecksForWorkspaceAsync(workspaceId); - } - - [TestMethod] - public async Task OnGetAsyncShouldCallGetRecentAlertsForWorkspaceAsyncWithCorrectParameters() - { - var workspaceId = Guid.NewGuid(); - var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; - var checks = new List(); - var alerts = new List(); - - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, 50).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); - _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(50); - - await _pageModel.OnGetAsync(workspaceId, null, null); - - await _mockAlertQuery.Received(1).GetRecentAlertsForWorkspaceAsync(workspaceId, 50); - } - - [TestMethod] - public async Task OnGetAsyncShouldNotCallQueryServicesWhenWorkspaceDoesNotExist() - { - var workspaceId = Guid.NewGuid(); - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(null)); - - await _pageModel.OnGetAsync(workspaceId, null, null); - - await _mockCheckQuery.DidNotReceive().GetChecksForWorkspaceAsync(Arg.Any()); - await _mockAlertQuery.DidNotReceive().GetRecentAlertsForWorkspaceAsync(Arg.Any(), Arg.Any()); - } - [TestMethod] public async Task OnGetAsyncShouldLoadWorkspaceContextWithCorrectParameters() { var workspaceId = Guid.NewGuid(); var workspace = new Workspace { Id = workspaceId, Name = "My Workspace" }; - var checks = new List(); - var alerts = new List(); - _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); - _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId).Returns(Task.FromResult(checks)); - _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any()).Returns(Task.FromResult(alerts)); - _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceIncidentTimelineViewModel())); - _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, Arg.Any(), Arg.Any()).Returns(Task.FromResult(new WorkspaceResponseTimeTrendsViewModel())); _mockGlobalSettings.DashboardRefreshIntervalSeconds.Returns(5); - _mockGlobalSettings.MaxRecentAlerts.Returns(20); + PrePopulateCache(workspaceId); await _pageModel.OnGetAsync(workspaceId, null, null); diff --git a/SAMA.Tests.Unit/Web/Pages/Workspaces/CreateModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Workspaces/CreateModelTests.cs index 86b9a86..d519bc6 100644 --- a/SAMA.Tests.Unit/Web/Pages/Workspaces/CreateModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Workspaces/CreateModelTests.cs @@ -18,7 +18,7 @@ public class CreateModelTests [TestInitialize] public void Setup() { - _mockWorkspaceCommand = Substitute.For(null!, null!); + _mockWorkspaceCommand = Substitute.For(null!, null!, null!); _mockMarkdownService = Substitute.For(); _pageModel = new CreateModel(_mockWorkspaceCommand, _mockMarkdownService); diff --git a/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs index f395c75..9a6ad90 100644 --- a/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Workspaces/DeleteModelTests.cs @@ -21,7 +21,7 @@ public class DeleteModelTests public void Setup() { _mockWorkspaceQuery = Substitute.For(null!, null!); - _mockWorkspaceCommand = Substitute.For(null!, null!); + _mockWorkspaceCommand = Substitute.For(null!, null!, null!); _pageModel = new DeleteModel(_mockWorkspaceQuery, _mockWorkspaceCommand); PageModelTestHelpers.ConfigurePageModel(_pageModel); diff --git a/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs index b90288d..e51123e 100644 --- a/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Workspaces/EditModelTests.cs @@ -22,7 +22,7 @@ public class EditModelTests public void Setup() { _mockWorkspaceQuery = Substitute.For(null!, null!); - _mockWorkspaceCommand = Substitute.For(null!, null!); + _mockWorkspaceCommand = Substitute.For(null!, null!, null!); _markdownService = new MarkdownService(); _pageModel = new EditModel(_mockWorkspaceQuery, _mockWorkspaceCommand, _markdownService); diff --git a/SAMA.Tests.Unit/Web/Services/DashboardCacheServiceTests.cs b/SAMA.Tests.Unit/Web/Services/DashboardCacheServiceTests.cs new file mode 100644 index 0000000..f0dea74 --- /dev/null +++ b/SAMA.Tests.Unit/Web/Services/DashboardCacheServiceTests.cs @@ -0,0 +1,304 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using SAMA.Web.Models; +using SAMA.Web.Services; +using SAMA.Web.Services.Queries; +using static SAMA.Web.Services.DashboardCacheService; + +namespace SAMA.Tests.Unit.Web.Services; + +[TestClass] +public class DashboardCacheServiceTests +{ + private DashboardCacheService _cacheService = null!; + private CheckQueryService _mockCheckQuery = null!; + private AlertQueryService _mockAlertQuery = null!; + private GlobalSettingsService _mockGlobalSettings = null!; + + [TestInitialize] + public void Setup() + { + _mockCheckQuery = Substitute.For(null!, null!, null!, null!); + _mockAlertQuery = Substitute.For((SAMA.Data.SamaDbContext)null!); + _mockGlobalSettings = Substitute.For(null, null, null, null); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddScoped(_ => _mockCheckQuery); + serviceCollection.AddScoped(_ => _mockAlertQuery); + serviceCollection.AddScoped(_ => _mockGlobalSettings); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + _cacheService = new DashboardCacheService(serviceProvider); + } + + private static WorkspaceDashboardData CreateWorkspaceData() + { + return new WorkspaceDashboardData([], []); + } + + private static WorkspaceIncidentTimelineViewModel CreateTimeline() + { + return new WorkspaceIncidentTimelineViewModel(); + } + + private static WorkspaceResponseTimeTrendsViewModel CreateTrends() + { + return new WorkspaceResponseTimeTrendsViewModel(); + } + + [TestMethod] + public async Task GetWorkspaceDataShouldPopulateOnCacheMiss() + { + var workspaceId = Guid.NewGuid(); + _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId, Arg.Any()) + .Returns(new List()); + _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any(), Arg.Any()) + .Returns(new List()); + + var result = await _cacheService.GetWorkspaceDataAsync(workspaceId); + + Assert.IsNotNull(result); + await _mockCheckQuery.Received(1).GetChecksForWorkspaceAsync(workspaceId, Arg.Any()); + } + + [TestMethod] + public async Task GetWorkspaceDataShouldReturnCachedOnHit() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + + var result = await _cacheService.GetWorkspaceDataAsync(workspaceId); + + Assert.IsNotNull(result); + await _mockCheckQuery.DidNotReceive().GetChecksForWorkspaceAsync(Arg.Any(), Arg.Any()); + } + + [TestMethod] + public async Task GetTimelineShouldPopulateOnCacheMiss() + { + var workspaceId = Guid.NewGuid(); + var expected = CreateTimeline(); + _mockCheckQuery.GetWorkspaceIncidentTimelineAsync(workspaceId, 24, Arg.Any(), Arg.Any()) + .Returns(expected); + + var result = await _cacheService.GetTimelineAsync(workspaceId, 24); + + Assert.AreSame(expected, result); + } + + [TestMethod] + public async Task GetTimelineShouldReturnCachedOnHit() + { + var workspaceId = Guid.NewGuid(); + var original = CreateTimeline(); + _cacheService.SetTimeline(workspaceId, 24, original); + + var result = await _cacheService.GetTimelineAsync(workspaceId, 24); + + Assert.AreSame(original, result); + await _mockCheckQuery.DidNotReceive().GetWorkspaceIncidentTimelineAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [TestMethod] + public async Task GetTrendsShouldPopulateOnCacheMiss() + { + var workspaceId = Guid.NewGuid(); + var expected = CreateTrends(); + _mockCheckQuery.GetWorkspaceResponseTimeTrendsAsync(workspaceId, 24, Arg.Any(), Arg.Any()) + .Returns(expected); + + var result = await _cacheService.GetTrendsAsync(workspaceId, 24); + + Assert.AreSame(expected, result); + } + + [TestMethod] + public async Task GetTrendsShouldReturnCachedOnHit() + { + var workspaceId = Guid.NewGuid(); + var original = CreateTrends(); + _cacheService.SetTrends(workspaceId, 24, original); + + var result = await _cacheService.GetTrendsAsync(workspaceId, 24); + + Assert.AreSame(original, result); + await _mockCheckQuery.DidNotReceive().GetWorkspaceResponseTimeTrendsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [TestMethod] + public void InvalidateWorkspaceShouldRemoveWorkspaceDataOnly() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + _cacheService.SetTimeline(workspaceId, 24, CreateTimeline()); + _cacheService.SetTrends(workspaceId, 24, CreateTrends()); + + _cacheService.InvalidateWorkspace(workspaceId); + + Assert.AreEqual(0, _cacheService.GetCachedWorkspaceIds().Count); + Assert.IsTrue(_cacheService.GetCachedTimelineKeys().Count > 0); + Assert.IsTrue(_cacheService.GetCachedTrendsKeys().Count > 0); + } + + [TestMethod] + public void InvalidateAllForWorkspaceShouldRemoveAllEntriesForWorkspace() + { + var workspaceId = Guid.NewGuid(); + var otherWorkspaceId = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + _cacheService.SetTimeline(workspaceId, 24, CreateTimeline()); + _cacheService.SetTrends(workspaceId, 24, CreateTrends()); + _cacheService.SetWorkspaceData(otherWorkspaceId, CreateWorkspaceData()); + _cacheService.SetTimeline(otherWorkspaceId, 24, CreateTimeline()); + + _cacheService.InvalidateAllForWorkspace(workspaceId); + + Assert.AreEqual(1, _cacheService.GetCachedWorkspaceIds().Count); + Assert.AreEqual(otherWorkspaceId, _cacheService.GetCachedWorkspaceIds()[0]); + Assert.AreEqual(1, _cacheService.GetCachedTimelineKeys().Count); + Assert.AreEqual(0, _cacheService.GetCachedTrendsKeys().Count); + } + + [TestMethod] + public async Task GetWorkspaceDataShouldOverwriteExistingEntry() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + + var result = await _cacheService.GetWorkspaceDataAsync(workspaceId); + + Assert.IsNotNull(result); + Assert.AreEqual(1, _cacheService.GetCachedWorkspaceIds().Count); + } + + [TestMethod] + public void TimelineCacheShouldKeySeparatelyByHours() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetTimeline(workspaceId, 6, CreateTimeline()); + _cacheService.SetTimeline(workspaceId, 24, CreateTimeline()); + + Assert.AreEqual(2, _cacheService.GetCachedTimelineKeys().Count); + } + + [TestMethod] + public void TrendsCacheShouldKeySeparatelyByHours() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetTrends(workspaceId, 6, CreateTrends()); + _cacheService.SetTrends(workspaceId, 24, CreateTrends()); + + Assert.AreEqual(2, _cacheService.GetCachedTrendsKeys().Count); + } + + [TestMethod] + public async Task GetWorkspaceDataShouldPreventCacheStampede() + { + var workspaceId = Guid.NewGuid(); + var callCount = 0; + var delayTcs = new TaskCompletionSource(); + + _mockCheckQuery.GetChecksForWorkspaceAsync(workspaceId, Arg.Any()) + .Returns(async _ => + { + Interlocked.Increment(ref callCount); + await delayTcs.Task; + return new List(); + }); + _mockAlertQuery.GetRecentAlertsForWorkspaceAsync(workspaceId, Arg.Any(), Arg.Any()) + .Returns(new List()); + + var task1 = _cacheService.GetWorkspaceDataAsync(workspaceId); + var task2 = _cacheService.GetWorkspaceDataAsync(workspaceId); + + delayTcs.SetResult(); + + var result1 = await task1; + var result2 = await task2; + + Assert.AreEqual(1, callCount); + Assert.IsNotNull(result1); + Assert.IsNotNull(result2); + } + + [TestMethod] + public void EvictStaleEntriesShouldNotRemoveRecentEntries() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + _cacheService.SetTimeline(workspaceId, 24, CreateTimeline()); + _cacheService.SetTrends(workspaceId, 24, CreateTrends()); + + _cacheService.EvictStaleEntries(); + + Assert.AreEqual(1, _cacheService.GetCachedWorkspaceIds().Count); + Assert.AreEqual(1, _cacheService.GetCachedTimelineKeys().Count); + Assert.AreEqual(1, _cacheService.GetCachedTrendsKeys().Count); + } + + [TestMethod] + public void SetWorkspaceDataShouldNotPreventEvictionOfStaleEntry() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + BackdateLastAccessedAt("_workspaceCache", workspaceId); + + _cacheService.SetWorkspaceData(workspaceId, CreateWorkspaceData()); + _cacheService.EvictStaleEntries(); + + Assert.AreEqual(0, _cacheService.GetCachedWorkspaceIds().Count); + } + + [TestMethod] + public void SetTimelineShouldNotPreventEvictionOfStaleEntry() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetTimeline(workspaceId, 24, CreateTimeline()); + BackdateLastAccessedAt("_timelineCache", (workspaceId, 24)); + + _cacheService.SetTimeline(workspaceId, 24, CreateTimeline()); + _cacheService.EvictStaleEntries(); + + Assert.AreEqual(0, _cacheService.GetCachedTimelineKeys().Count); + } + + [TestMethod] + public void SetTrendsShouldNotPreventEvictionOfStaleEntry() + { + var workspaceId = Guid.NewGuid(); + _cacheService.SetTrends(workspaceId, 24, CreateTrends()); + BackdateLastAccessedAt("_trendsCache", (workspaceId, 24)); + + _cacheService.SetTrends(workspaceId, 24, CreateTrends()); + _cacheService.EvictStaleEntries(); + + Assert.AreEqual(0, _cacheService.GetCachedTrendsKeys().Count); + } + + [TestMethod] + public void GetCachedWorkspaceIdsShouldReturnAllCachedEntries() + { + var workspaceId1 = Guid.NewGuid(); + var workspaceId2 = Guid.NewGuid(); + _cacheService.SetWorkspaceData(workspaceId1, CreateWorkspaceData()); + _cacheService.SetWorkspaceData(workspaceId2, CreateWorkspaceData()); + + var activeIds = _cacheService.GetCachedWorkspaceIds(); + + Assert.AreEqual(2, activeIds.Count); + CollectionAssert.Contains(activeIds, workspaceId1); + CollectionAssert.Contains(activeIds, workspaceId2); + } + + private void BackdateLastAccessedAt(string fieldName, TKey key) + { + var field = typeof(DashboardCacheService).GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var dict = (System.Collections.IDictionary)field.GetValue(_cacheService)!; + var entry = dict[key!]; + var prop = entry!.GetType().GetProperty("LastAccessedAt")!; + prop.SetValue(entry, DateTimeOffset.UtcNow.AddMinutes(-15)); + } +} diff --git a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs index 7bb616c..5fce159 100644 --- a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs +++ b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs @@ -10,10 +10,9 @@ namespace SAMA.Web.Pages.Dashboard; [RequireWorkspaceViewAccess] public class IndexModel( WorkspaceQueryService _workspaceQueryService, - CheckQueryService _checkQueryService, - AlertQueryService _alertQueryService, GlobalSettingsService _globalSettings, - MarkdownService _markdownService) + MarkdownService _markdownService, + DashboardCacheService _cacheService) : WorkspacePageModel(_workspaceQueryService) { public IList Checks { get; set; } = []; @@ -45,20 +44,18 @@ public async Task OnGetAsync(Guid workspaceId, int? timelineHours TimelineHours = timelineHours ?? 24; TrendsHours = trendsHours ?? 24; + var workspaceData = await _cacheService.GetWorkspaceDataAsync(WorkspaceId); + Checks = workspaceData.Checks; + RecentAlerts = workspaceData.RecentAlerts; + var workspace = await _workspaceQueryService.GetWorkspaceDetailsAsync(WorkspaceId); if (workspace != null) { DashboardMessageHtml = _markdownService.RenderToHtml(workspace.DashboardMessage); } - Checks = await _checkQueryService.GetChecksForWorkspaceAsync(WorkspaceId); - RecentAlerts = await _alertQueryService.GetRecentAlertsForWorkspaceAsync( - WorkspaceId, - _globalSettings.MaxRecentAlerts); - - IncidentTimeline = await _checkQueryService.GetWorkspaceIncidentTimelineAsync(WorkspaceId, TimelineHours); - - ResponseTimeTrends = await _checkQueryService.GetWorkspaceResponseTimeTrendsAsync(WorkspaceId, TrendsHours); + IncidentTimeline = await _cacheService.GetTimelineAsync(WorkspaceId, TimelineHours); + ResponseTimeTrends = await _cacheService.GetTrendsAsync(WorkspaceId, TrendsHours); return Page(); } diff --git a/SAMA.Web/Program.cs b/SAMA.Web/Program.cs index 50b8c85..2eb8223 100644 --- a/SAMA.Web/Program.cs +++ b/SAMA.Web/Program.cs @@ -41,15 +41,7 @@ .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}\n {Message:lj}{NewLine}{Exception}") .WriteTo.Sink(inMemoryLogSink)); -if (builder.Environment.IsDevelopment()) -{ - // NOTE: this prevents modern hot reload functionality from working reliably - builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); -} -else -{ - builder.Services.AddRazorPages(); -} +builder.Services.AddRazorPages(); builder.Services.AddControllers(config => { // Require authenticated admin users by default @@ -203,6 +195,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -265,6 +258,19 @@ .WithIdentity("data-cleanup-trigger") .WithCronSchedule("0 0 5 * * ?") .StartAt(DateBuilder.FutureDate(1, IntervalUnit.Minute))); + + // Register dashboard cache refresh job (runs every 30 seconds) + q.AddJob(opts => opts + .WithIdentity("dashboard-cache-refresh-job") + .StoreDurably()); + + q.AddTrigger(opts => opts + .ForJob("dashboard-cache-refresh-job") + .WithIdentity("dashboard-cache-refresh-trigger") + .WithSimpleSchedule(x => x + .WithIntervalInSeconds(30) + .RepeatForever()) + .StartAt(DateBuilder.FutureDate(15, IntervalUnit.Second))); }); builder.Services.AddQuartzHostedService(options => diff --git a/SAMA.Web/SAMA.Web.csproj b/SAMA.Web/SAMA.Web.csproj index 49431b8..f277900 100644 --- a/SAMA.Web/SAMA.Web.csproj +++ b/SAMA.Web/SAMA.Web.csproj @@ -17,7 +17,6 @@ - diff --git a/SAMA.Web/Services/Commands/AlertCommandService.cs b/SAMA.Web/Services/Commands/AlertCommandService.cs index 48e9f81..559fd64 100644 --- a/SAMA.Web/Services/Commands/AlertCommandService.cs +++ b/SAMA.Web/Services/Commands/AlertCommandService.cs @@ -11,6 +11,7 @@ public class AlertCommandService( CheckSchedulerService _schedulerService, EventSubscriptionService _eventSubscriptionService, AlertChangeDetectionService _alertChangeDetectionService, + DashboardCacheService _dashboardCacheService, ILogger _logger) { public virtual async Task CreateAlertAsync( @@ -57,6 +58,8 @@ public virtual async Task CreateAlertAsync( _dbContext.Alerts.Add(alert); await _dbContext.SaveChangesAsync(cancellationToken); + _dashboardCacheService.InvalidateWorkspace(check.WorkspaceId); + _logger.LogInformation( "User {User} created alert {AlertName} for check {CheckId} with {ChannelCount} channels", performedBy, @@ -174,6 +177,8 @@ public virtual async Task UpdateAlertAsync( await _dbContext.SaveChangesAsync(cancellationToken); + _dashboardCacheService.InvalidateWorkspace(alertToUpdate.Check.WorkspaceId); + _logger.LogInformation( "User {User} updated alert {AlertName} (Id: {AlertId}) with {ChannelCount} channels", performedBy, @@ -243,6 +248,8 @@ public virtual async Task DeleteAlertAsync( _dbContext.Alerts.Remove(alert); await _dbContext.SaveChangesAsync(cancellationToken); + _dashboardCacheService.InvalidateWorkspace(alert.Check.WorkspaceId); + _logger.LogInformation( "User {User} deleted alert {AlertName} (Id: {AlertId})", performedBy, diff --git a/SAMA.Web/Services/Commands/CheckCommandService.cs b/SAMA.Web/Services/Commands/CheckCommandService.cs index 27910ed..aa372ce 100644 --- a/SAMA.Web/Services/Commands/CheckCommandService.cs +++ b/SAMA.Web/Services/Commands/CheckCommandService.cs @@ -12,6 +12,7 @@ public class CheckCommandService( CheckSchedulerService _checkSchedulerService, EventSubscriptionService _eventSubscriptionService, CheckChangeDetectionService _changeDetectionService, + DashboardCacheService _dashboardCacheService, ILogger _logger) { public virtual async Task CreateCheckAsync( @@ -74,6 +75,8 @@ public virtual async Task CreateCheckAsync( _logger.LogInformation("Scheduled check {CheckId} for execution", check.Id); } + _dashboardCacheService.InvalidateAllForWorkspace(workspaceId); + // Trigger lifecycle event for check creation var workspace = await _samaDbContext.Workspaces.FindAsync([workspaceId], cancellationToken); var lifecycleContext = new LifecycleEventContext @@ -161,6 +164,8 @@ await _eventSubscriptionService.TriggerLifecycleEventAsync( lifecycleContext, cancellationToken); + _dashboardCacheService.InvalidateAllForWorkspace(checkToUpdate.WorkspaceId); + if (enabled) { await _checkSchedulerService.ScheduleCheckAsync(checkToUpdate.Id, checkToUpdate.Schedule); @@ -206,6 +211,8 @@ public virtual async Task DeleteCheckAsync( _samaDbContext.Checks.Remove(check); await _samaDbContext.SaveChangesAsync(cancellationToken); + _dashboardCacheService.InvalidateAllForWorkspace(workspaceId); + _logger.LogInformation( "User {User} deleted check {CheckName} (Id: {CheckId})", performedBy, diff --git a/SAMA.Web/Services/Commands/WorkspaceCommandService.cs b/SAMA.Web/Services/Commands/WorkspaceCommandService.cs index 7b916eb..2fa90e1 100644 --- a/SAMA.Web/Services/Commands/WorkspaceCommandService.cs +++ b/SAMA.Web/Services/Commands/WorkspaceCommandService.cs @@ -4,7 +4,7 @@ namespace SAMA.Web.Services.Commands; -public class WorkspaceCommandService(SamaDbContext _dbContext, ILogger _logger) +public class WorkspaceCommandService(SamaDbContext _dbContext, DashboardCacheService _dashboardCacheService, ILogger _logger) { public virtual async Task CreateWorkspaceAsync( string name, @@ -59,6 +59,8 @@ public virtual async Task UpdateWorkspaceAsync( await _dbContext.SaveChangesAsync(cancellationToken); + _dashboardCacheService.InvalidateWorkspace(workspaceId); + _logger.LogInformation( "User {User} updated workspace {WorkspaceName} (Id: {WorkspaceId})", performedBy, @@ -86,6 +88,8 @@ public virtual async Task DeleteWorkspaceAsync( _dbContext.Workspaces.Remove(workspace); await _dbContext.SaveChangesAsync(cancellationToken); + _dashboardCacheService.InvalidateAllForWorkspace(workspaceId); + _logger.LogInformation( "User {User} deleted workspace {WorkspaceName} (Id: {WorkspaceId})", performedBy, diff --git a/SAMA.Web/Services/DashboardCacheRefreshJob.cs b/SAMA.Web/Services/DashboardCacheRefreshJob.cs new file mode 100644 index 0000000..66a8155 --- /dev/null +++ b/SAMA.Web/Services/DashboardCacheRefreshJob.cs @@ -0,0 +1,81 @@ +using Quartz; + +namespace SAMA.Web.Services; + +[DisallowConcurrentExecution] +public class DashboardCacheRefreshJob( + DashboardCacheService _cacheService, + ILogger _logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var cancellationToken = context.CancellationToken; + _cacheService.EvictStaleEntries(); + + var workspaceIds = _cacheService.GetCachedWorkspaceIds(); + var timelineKeys = _cacheService.GetCachedTimelineKeys(); + var trendsKeys = _cacheService.GetCachedTrendsKeys(); + + if (workspaceIds.Count == 0 && timelineKeys.Count == 0 && trendsKeys.Count == 0) + { + return; + } + + _logger.LogDebug( + "Refreshing dashboard cache: {WorkspaceCount} workspace(s), {TimelineCount} timeline(s), {TrendsCount} trends", + workspaceIds.Count, + timelineKeys.Count, + trendsKeys.Count); + + foreach (var workspaceId in workspaceIds) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await _cacheService.RefreshWorkspaceDataAsync(workspaceId, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to refresh dashboard cache for workspace {WorkspaceId}", workspaceId); + } + } + + foreach (var (workspaceId, hours) in timelineKeys) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await _cacheService.RefreshTimelineAsync(workspaceId, hours, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to refresh timeline cache for workspace {WorkspaceId}, hours {Hours}", workspaceId, hours); + } + } + + foreach (var (workspaceId, hours) in trendsKeys) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await _cacheService.RefreshTrendsAsync(workspaceId, hours, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to refresh trends cache for workspace {WorkspaceId}, hours {Hours}", workspaceId, hours); + } + } + } +} diff --git a/SAMA.Web/Services/DashboardCacheService.cs b/SAMA.Web/Services/DashboardCacheService.cs new file mode 100644 index 0000000..8551e07 --- /dev/null +++ b/SAMA.Web/Services/DashboardCacheService.cs @@ -0,0 +1,350 @@ +using System.Collections.Concurrent; +using SAMA.Web.Models; +using SAMA.Web.Services.Queries; + +namespace SAMA.Web.Services; + +public class DashboardCacheService(IServiceProvider _serviceProvider) +{ + private const int MinHours = 1; + private const int MaxHours = 168; + private const int MaxWorkspaceEntries = 50; + private const int MaxTimelineEntries = 50; + private const int MaxTrendsEntries = 50; + private static readonly TimeSpan EvictionThreshold = TimeSpan.FromMinutes(10); + + private readonly ConcurrentDictionary> _workspaceCache = new(); + private readonly ConcurrentDictionary<(Guid WorkspaceId, int Hours), CacheEntry> _timelineCache = new(); + private readonly ConcurrentDictionary<(Guid WorkspaceId, int Hours), CacheEntry> _trendsCache = new(); + + private readonly ConcurrentDictionary _workspaceLocks = new(); + private readonly ConcurrentDictionary<(Guid, int), SemaphoreSlim> _timelineLocks = new(); + private readonly ConcurrentDictionary<(Guid, int), SemaphoreSlim> _trendsLocks = new(); + + public async Task GetWorkspaceDataAsync(Guid workspaceId, CancellationToken cancellationToken = default) + { + if (_workspaceCache.TryGetValue(workspaceId, out var entry)) + { + entry.LastAccessedAt = DateTimeOffset.UtcNow; + return entry.Data; + } + + var semaphore = _workspaceLocks.GetOrAdd(workspaceId, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + if (_workspaceCache.TryGetValue(workspaceId, out entry)) + { + entry.LastAccessedAt = DateTimeOffset.UtcNow; + return entry.Data; + } + + var data = await PopulateWorkspaceDataAsync(workspaceId, cancellationToken); + SetWorkspaceData(workspaceId, data); + return data; + } + finally + { + semaphore.Release(); + } + } + + public async Task GetTimelineAsync(Guid workspaceId, int hours, CancellationToken cancellationToken = default) + { + hours = Math.Clamp(hours, MinHours, MaxHours); + var key = (workspaceId, hours); + if (_timelineCache.TryGetValue(key, out var entry)) + { + entry.LastAccessedAt = DateTimeOffset.UtcNow; + return entry.Data; + } + + var semaphore = _timelineLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + if (_timelineCache.TryGetValue(key, out entry)) + { + entry.LastAccessedAt = DateTimeOffset.UtcNow; + return entry.Data; + } + + var data = await PopulateTimelineAsync(workspaceId, hours, cancellationToken); + SetTimeline(workspaceId, hours, data); + return data; + } + finally + { + semaphore.Release(); + } + } + + public async Task GetTrendsAsync(Guid workspaceId, int hours, CancellationToken cancellationToken = default) + { + hours = Math.Clamp(hours, MinHours, MaxHours); + var key = (workspaceId, hours); + if (_trendsCache.TryGetValue(key, out var entry)) + { + entry.LastAccessedAt = DateTimeOffset.UtcNow; + return entry.Data; + } + + var semaphore = _trendsLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + if (_trendsCache.TryGetValue(key, out entry)) + { + entry.LastAccessedAt = DateTimeOffset.UtcNow; + return entry.Data; + } + + var data = await PopulateTrendsAsync(workspaceId, hours, cancellationToken); + SetTrends(workspaceId, hours, data); + return data; + } + finally + { + semaphore.Release(); + } + } + + public async Task RefreshWorkspaceDataAsync(Guid workspaceId, CancellationToken cancellationToken = default) + { + var semaphore = _workspaceLocks.GetOrAdd(workspaceId, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + var data = await PopulateWorkspaceDataAsync(workspaceId, cancellationToken); + SetWorkspaceData(workspaceId, data); + } + finally + { + semaphore.Release(); + } + } + + public async Task RefreshTimelineAsync(Guid workspaceId, int hours, CancellationToken cancellationToken = default) + { + hours = Math.Clamp(hours, MinHours, MaxHours); + var key = (workspaceId, hours); + var semaphore = _timelineLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + var data = await PopulateTimelineAsync(workspaceId, hours, cancellationToken); + SetTimeline(workspaceId, hours, data); + } + finally + { + semaphore.Release(); + } + } + + public async Task RefreshTrendsAsync(Guid workspaceId, int hours, CancellationToken cancellationToken = default) + { + hours = Math.Clamp(hours, MinHours, MaxHours); + var key = (workspaceId, hours); + var semaphore = _trendsLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + try + { + var data = await PopulateTrendsAsync(workspaceId, hours, cancellationToken); + SetTrends(workspaceId, hours, data); + } + finally + { + semaphore.Release(); + } + } + + internal void SetWorkspaceData(Guid workspaceId, WorkspaceDashboardData data) + { + _workspaceCache.AddOrUpdate( + workspaceId, + _ => new CacheEntry(data), + (_, existing) => + { + existing.Data = data; + existing.LastRefreshedAt = DateTimeOffset.UtcNow; + return existing; + }); + + EnforceSizeLimit(_workspaceCache, MaxWorkspaceEntries); + } + + internal void SetTimeline(Guid workspaceId, int hours, WorkspaceIncidentTimelineViewModel data) + { + var key = (workspaceId, hours); + _timelineCache.AddOrUpdate( + key, + _ => new CacheEntry(data), + (_, existing) => + { + existing.Data = data; + existing.LastRefreshedAt = DateTimeOffset.UtcNow; + return existing; + }); + + EnforceSizeLimit(_timelineCache, MaxTimelineEntries); + } + + internal void SetTrends(Guid workspaceId, int hours, WorkspaceResponseTimeTrendsViewModel data) + { + var key = (workspaceId, hours); + _trendsCache.AddOrUpdate( + key, + _ => new CacheEntry(data), + (_, existing) => + { + existing.Data = data; + existing.LastRefreshedAt = DateTimeOffset.UtcNow; + return existing; + }); + + EnforceSizeLimit(_trendsCache, MaxTrendsEntries); + } + + public void InvalidateWorkspace(Guid workspaceId) + { + _workspaceCache.TryRemove(workspaceId, out _); + } + + public void InvalidateAllForWorkspace(Guid workspaceId) + { + _workspaceCache.TryRemove(workspaceId, out _); + + foreach (var key in _timelineCache.Keys) + { + if (key.WorkspaceId == workspaceId) + { + _timelineCache.TryRemove(key, out _); + } + } + + foreach (var key in _trendsCache.Keys) + { + if (key.WorkspaceId == workspaceId) + { + _trendsCache.TryRemove(key, out _); + } + } + } + + public List GetCachedWorkspaceIds() + { + return _workspaceCache.Keys.ToList(); + } + + public List<(Guid WorkspaceId, int Hours)> GetCachedTimelineKeys() + { + return _timelineCache.Keys.ToList(); + } + + public List<(Guid WorkspaceId, int Hours)> GetCachedTrendsKeys() + { + return _trendsCache.Keys.ToList(); + } + + public void EvictStaleEntries() + { + var cutoff = DateTimeOffset.UtcNow - EvictionThreshold; + + foreach (var kvp in _workspaceCache) + { + if (kvp.Value.LastAccessedAt < cutoff) + { + _workspaceCache.TryRemove(kvp.Key, out _); + } + } + + foreach (var kvp in _timelineCache) + { + if (kvp.Value.LastAccessedAt < cutoff) + { + _timelineCache.TryRemove(kvp.Key, out _); + } + } + + foreach (var kvp in _trendsCache) + { + if (kvp.Value.LastAccessedAt < cutoff) + { + _trendsCache.TryRemove(kvp.Key, out _); + } + } + } + + private static void EnforceSizeLimit(ConcurrentDictionary> cache, int maxEntries) + where TKey : notnull + where TValue : class + { + var count = cache.Count; + if (count <= maxEntries) + { + return; + } + + var toRemove = cache + .OrderBy(kvp => kvp.Value.LastAccessedAt) + .Take(count - maxEntries) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in toRemove) + { + cache.TryRemove(key, out _); + } + } + + private async Task PopulateWorkspaceDataAsync(Guid workspaceId, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var checkQueryService = scope.ServiceProvider.GetRequiredService(); + var alertQueryService = scope.ServiceProvider.GetRequiredService(); + var globalSettings = scope.ServiceProvider.GetRequiredService(); + + var checks = await checkQueryService.GetChecksForWorkspaceAsync(workspaceId, cancellationToken); + var recentAlerts = await alertQueryService.GetRecentAlertsForWorkspaceAsync( + workspaceId, globalSettings.MaxRecentAlerts, cancellationToken); + + return new WorkspaceDashboardData( + Checks: checks, + RecentAlerts: recentAlerts + ); + } + + private async Task PopulateTimelineAsync(Guid workspaceId, int hours, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var checkQueryService = scope.ServiceProvider.GetRequiredService(); + return await checkQueryService.GetWorkspaceIncidentTimelineAsync(workspaceId, hours, cancellationToken: cancellationToken); + } + + private async Task PopulateTrendsAsync(Guid workspaceId, int hours, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var checkQueryService = scope.ServiceProvider.GetRequiredService(); + return await checkQueryService.GetWorkspaceResponseTimeTrendsAsync(workspaceId, hours, cancellationToken: cancellationToken); + } + + public record WorkspaceDashboardData( + IList Checks, + IList RecentAlerts); + + internal class CacheEntry where TValue : class + { + public TValue Data { get; set; } + + public DateTimeOffset LastRefreshedAt { get; set; } + + public DateTimeOffset LastAccessedAt { get; set; } + + public CacheEntry(TValue data) + { + Data = data; + LastRefreshedAt = DateTimeOffset.UtcNow; + LastAccessedAt = DateTimeOffset.UtcNow; + } + } +}