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;
+ }
+ }
+}