From 941bb1409b5890cd84281797e31b070d9ba1fd00 Mon Sep 17 00:00:00 2001 From: Sasha Kotlyar Date: Sun, 12 Apr 2026 20:01:58 -0400 Subject: [PATCH 1/4] Improve some dashboard loading performance --- .../Web/Pages/Dashboard/IndexModelTests.cs | 4 ++-- SAMA.Web/Pages/Dashboard/Index.cshtml.cs | 7 ++----- SAMA.Web/Services/Queries/CheckQueryService.cs | 11 +++++++---- SAMA.Web/Services/Queries/WorkspaceQueryService.cs | 11 +++++++++++ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs index 311df14..87bd831 100644 --- a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs @@ -32,8 +32,8 @@ public void Setup() _pageModel = new IndexModel(_mockWorkspaceQuery, _mockGlobalSettings, _markdownService, _cacheService); PageModelTestHelpers.ConfigurePageModel(_pageModel); - _mockWorkspaceQuery.GetWorkspaceDetailsAsync(Arg.Any(), Arg.Any()) - .Returns(new WorkspaceDetailsViewModel()); + _mockWorkspaceQuery.GetDashboardMessageAsync(Arg.Any(), Arg.Any()) + .Returns((string?)null); } private void PrePopulateCache(Guid workspaceId, List? checks = null, List? alerts = null) diff --git a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs index 5fce159..db75f89 100644 --- a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs +++ b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs @@ -48,11 +48,8 @@ public async Task OnGetAsync(Guid workspaceId, int? timelineHours Checks = workspaceData.Checks; RecentAlerts = workspaceData.RecentAlerts; - var workspace = await _workspaceQueryService.GetWorkspaceDetailsAsync(WorkspaceId); - if (workspace != null) - { - DashboardMessageHtml = _markdownService.RenderToHtml(workspace.DashboardMessage); - } + var dashboardMessage = await _workspaceQueryService.GetDashboardMessageAsync(WorkspaceId); + DashboardMessageHtml = _markdownService.RenderToHtml(dashboardMessage); IncidentTimeline = await _cacheService.GetTimelineAsync(WorkspaceId, TimelineHours); ResponseTimeTrends = await _cacheService.GetTrendsAsync(WorkspaceId, TrendsHours); diff --git a/SAMA.Web/Services/Queries/CheckQueryService.cs b/SAMA.Web/Services/Queries/CheckQueryService.cs index e8cb133..4910cc7 100644 --- a/SAMA.Web/Services/Queries/CheckQueryService.cs +++ b/SAMA.Web/Services/Queries/CheckQueryService.cs @@ -351,13 +351,15 @@ public virtual async Task GetWorkspaceIncide .Select(cr => new { cr.CheckId, cr.Status, cr.CheckedAt, cr.ErrorMessage }) .ToListAsync(cancellationToken); + var resultsByCheckId = allResults.ToLookup(r => r.CheckId); + // For disabled checks, compute cutoff: the start of the increment containing their last result. // Data in that increment and beyond is excluded since we don't know exactly when the check was disabled. // Disabled checks with no results use MinValue so they are excluded from all increments. var disabledCheckCutoffs = new Dictionary(); foreach (var check in checks.Where(c => !c.Enabled)) { - var lastResult = allResults.Where(r => r.CheckId == check.Id).MaxBy(r => r.CheckedAt); + var lastResult = resultsByCheckId[check.Id].MaxBy(r => r.CheckedAt); disabledCheckCutoffs[check.Id] = lastResult != null ? AlignToIncrementBoundary(lastResult.CheckedAt, incrementMinutes, roundDown: true) : DateTimeOffset.MinValue; @@ -393,8 +395,8 @@ public virtual async Task GetWorkspaceIncide foreach (var checkId in activeCheckIds) { // Get all results for this check within this increment's time range - var resultsInIncrement = allResults - .Where(r => r.CheckId == checkId && r.CheckedAt >= currentTime && r.CheckedAt < incrementEnd) + var resultsInIncrement = resultsByCheckId[checkId] + .Where(r => r.CheckedAt >= currentTime && r.CheckedAt < incrementEnd) .ToList(); if (resultsInIncrement.Count > 0) @@ -497,11 +499,12 @@ public virtual async Task GetWorkspaceResp .Select(cr => new { cr.CheckId, cr.CheckedAt, cr.ResponseTimeMs }) .ToListAsync(cancellationToken); + var resultsByCheckId = allResults.ToLookup(r => r.CheckId); var series = new List(); foreach (var check in checks) { - var checkResults = allResults.Where(r => r.CheckId == check.Id).ToList(); + var checkResults = resultsByCheckId[check.Id].ToList(); if (!check.Enabled && checkResults.Count == 0) { diff --git a/SAMA.Web/Services/Queries/WorkspaceQueryService.cs b/SAMA.Web/Services/Queries/WorkspaceQueryService.cs index f0b460c..672272d 100644 --- a/SAMA.Web/Services/Queries/WorkspaceQueryService.cs +++ b/SAMA.Web/Services/Queries/WorkspaceQueryService.cs @@ -48,6 +48,17 @@ public virtual async Task> GetWorkspacesAsync( return workspaces; } + public virtual async Task GetDashboardMessageAsync( + Guid workspaceId, + CancellationToken cancellationToken = default) + { + return await _dbContext.Workspaces + .AsNoTracking() + .Where(w => w.Id == workspaceId) + .Select(w => w.DashboardMessage) + .FirstOrDefaultAsync(cancellationToken); + } + public virtual async Task GetWorkspaceDetailsAsync( Guid workspaceId, CancellationToken cancellationToken = default) From 39dd72e40987e1a9bfdf89b3d7d69a6726f4a51d Mon Sep 17 00:00:00 2001 From: Sasha Kotlyar Date: Sun, 12 Apr 2026 22:58:00 -0400 Subject: [PATCH 2/4] Split chart loading into separate calls --- .../Web/Pages/Dashboard/IndexModelTests.cs | 86 +++++++++++++++++++ SAMA.Web/Pages/Dashboard/Index.cshtml | 13 ++- SAMA.Web/Pages/Dashboard/Index.cshtml.cs | 33 +++++-- .../Dashboard/_IncidentTimelineChart.cshtml | 44 ++++++---- .../Dashboard/_ResponseTimeTrendsChart.cshtml | 32 ++++--- 5 files changed, 171 insertions(+), 37 deletions(-) diff --git a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs index 87bd831..4d2fac8 100644 --- a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs +++ b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs @@ -197,4 +197,90 @@ public async Task OnGetAsyncShouldLoadWorkspaceContextWithCorrectParameters() Assert.AreEqual(workspaceId, _pageModel.WorkspaceId); Assert.AreEqual("My Workspace", _pageModel.WorkspaceName); } + + [TestMethod] + public async Task OnGetTimelineAsyncShouldReturnNotFoundWhenWorkspaceDoesNotExist() + { + var workspaceId = Guid.NewGuid(); + _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(null)); + + var result = await _pageModel.OnGetTimelineAsync(workspaceId, null); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task OnGetTimelineAsyncShouldReturnPartialWithTimelineData() + { + var workspaceId = Guid.NewGuid(); + var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; + _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); + _cacheService.SetTimeline(workspaceId, 6, new WorkspaceIncidentTimelineViewModel { Hours = 6 }); + + var result = await _pageModel.OnGetTimelineAsync(workspaceId, 6); + + Assert.IsInstanceOfType(result); + var partial = (PartialViewResult)result; + var model = (WorkspaceIncidentTimelineViewModel)partial.Model!; + Assert.AreEqual(6, model.Hours); + } + + [TestMethod] + public async Task OnGetTimelineAsyncShouldDefaultTo24Hours() + { + var workspaceId = Guid.NewGuid(); + var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; + _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); + _cacheService.SetTimeline(workspaceId, 24, new WorkspaceIncidentTimelineViewModel { Hours = 24 }); + + var result = await _pageModel.OnGetTimelineAsync(workspaceId, null); + + Assert.IsInstanceOfType(result); + var partial = (PartialViewResult)result; + var model = (WorkspaceIncidentTimelineViewModel)partial.Model!; + Assert.AreEqual(24, model.Hours); + } + + [TestMethod] + public async Task OnGetTrendsAsyncShouldReturnNotFoundWhenWorkspaceDoesNotExist() + { + var workspaceId = Guid.NewGuid(); + _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(null)); + + var result = await _pageModel.OnGetTrendsAsync(workspaceId, null); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task OnGetTrendsAsyncShouldReturnPartialWithTrendsData() + { + var workspaceId = Guid.NewGuid(); + var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; + _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); + _cacheService.SetTrends(workspaceId, 3, new WorkspaceResponseTimeTrendsViewModel { Hours = 3 }); + + var result = await _pageModel.OnGetTrendsAsync(workspaceId, 3); + + Assert.IsInstanceOfType(result); + var partial = (PartialViewResult)result; + var model = (WorkspaceResponseTimeTrendsViewModel)partial.Model!; + Assert.AreEqual(3, model.Hours); + } + + [TestMethod] + public async Task OnGetTrendsAsyncShouldDefaultTo24Hours() + { + var workspaceId = Guid.NewGuid(); + var workspace = new Workspace { Id = workspaceId, Name = "Test Workspace" }; + _mockWorkspaceQuery.GetWorkspaceByIdAsync(workspaceId).Returns(Task.FromResult(workspace)); + _cacheService.SetTrends(workspaceId, 24, new WorkspaceResponseTimeTrendsViewModel { Hours = 24 }); + + var result = await _pageModel.OnGetTrendsAsync(workspaceId, null); + + Assert.IsInstanceOfType(result); + var partial = (PartialViewResult)result; + var model = (WorkspaceResponseTimeTrendsViewModel)partial.Model!; + Assert.AreEqual(24, model.Hours); + } } diff --git a/SAMA.Web/Pages/Dashboard/Index.cshtml b/SAMA.Web/Pages/Dashboard/Index.cshtml index 2412e34..ad51e90 100644 --- a/SAMA.Web/Pages/Dashboard/Index.cshtml +++ b/SAMA.Web/Pages/Dashboard/Index.cshtml @@ -1,6 +1,7 @@ @page @using SAMA.Shared.Constants @using SAMA.Web.Extensions +@using SAMA.Web.Models @model SAMA.Web.Pages.Dashboard.IndexModel @{ ViewData["Title"] = "Dashboard"; @@ -150,12 +151,20 @@ else
- +
+
- +
+
diff --git a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs index db75f89..b502237 100644 --- a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs +++ b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs @@ -19,10 +19,6 @@ public class IndexModel( public IList RecentAlerts { get; set; } = []; - public WorkspaceIncidentTimelineViewModel IncidentTimeline { get; set; } = new(); - - public WorkspaceResponseTimeTrendsViewModel ResponseTimeTrends { get; set; } = new(); - public string DashboardMessageHtml { get; set; } = string.Empty; public int RefreshIntervalSeconds { get; set; } @@ -51,9 +47,32 @@ public async Task OnGetAsync(Guid workspaceId, int? timelineHours var dashboardMessage = await _workspaceQueryService.GetDashboardMessageAsync(WorkspaceId); DashboardMessageHtml = _markdownService.RenderToHtml(dashboardMessage); - IncidentTimeline = await _cacheService.GetTimelineAsync(WorkspaceId, TimelineHours); - ResponseTimeTrends = await _cacheService.GetTrendsAsync(WorkspaceId, TrendsHours); - return Page(); } + + public async Task OnGetTimelineAsync(Guid workspaceId, int? timelineHours) + { + var result = await LoadWorkspaceContextAsync(workspaceId, "Dashboard"); + if (result != null) + { + return result; + } + + var hours = timelineHours ?? 24; + var timeline = await _cacheService.GetTimelineAsync(WorkspaceId, hours); + return Partial("_IncidentTimelineChart", timeline); + } + + public async Task OnGetTrendsAsync(Guid workspaceId, int? trendsHours) + { + var result = await LoadWorkspaceContextAsync(workspaceId, "Dashboard"); + if (result != null) + { + return result; + } + + var hours = trendsHours ?? 24; + var trends = await _cacheService.GetTrendsAsync(WorkspaceId, hours); + return Partial("_ResponseTimeTrendsChart", trends); + } } diff --git a/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml b/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml index a5dc854..6ee8d96 100644 --- a/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml +++ b/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml @@ -13,51 +13,63 @@
-
+
Loading...
-
+
No timeline data available for the selected time period.
- +@if (Model.Increments.Count > 0) +{ + +} +else +{ + +} +@if (Model.Series.Count > 0) +{ + +} +else +{ + +} -} -else -{ - -} + -} -else -{ - -} + """, + "text/html"); } public async Task OnGetTrendsAsync(Guid workspaceId, int? trendsHours) @@ -73,6 +82,9 @@ public async Task OnGetTrendsAsync(Guid workspaceId, int? trendsH var hours = trendsHours ?? 24; var trends = await _cacheService.GetTrendsAsync(WorkspaceId, hours); - return Partial("_ResponseTimeTrendsChart", trends); + var json = JsonSerializer.Serialize(trends, CamelCaseOptions); + return Content( + $"""""", + "text/html"); } } diff --git a/SAMA.Web/Services/Queries/CheckQueryService.cs b/SAMA.Web/Services/Queries/CheckQueryService.cs index decde2e..64f3ede 100644 --- a/SAMA.Web/Services/Queries/CheckQueryService.cs +++ b/SAMA.Web/Services/Queries/CheckQueryService.cs @@ -359,9 +359,10 @@ public virtual async Task GetWorkspaceIncide var disabledCheckCutoffs = new Dictionary(); foreach (var check in checks.Where(c => !c.Enabled)) { - var lastResult = resultsByCheckId[check.Id].OrderByDescending(r => r.CheckedAt).FirstOrDefault(); - disabledCheckCutoffs[check.Id] = lastResult != null - ? AlignToIncrementBoundary(lastResult.CheckedAt, incrementMinutes, roundDown: true) + var checkResults = resultsByCheckId[check.Id]; + var lastCheckedAt = checkResults.Any() ? checkResults.Max(r => r.CheckedAt) : (DateTimeOffset?)null; + disabledCheckCutoffs[check.Id] = lastCheckedAt.HasValue + ? AlignToIncrementBoundary(lastCheckedAt.Value, incrementMinutes, roundDown: true) : DateTimeOffset.MinValue; }