diff --git a/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs b/SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs index 311df14..9945d8a 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) @@ -197,4 +197,92 @@ 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 OnGetTimelineAsyncShouldReturnOobContentWithTimelineData() + { + 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 content = (ContentResult)result; + Assert.AreEqual("text/html", content.ContentType); + Assert.IsTrue(content.Content!.Contains("incidentTimelineChartData")); + Assert.IsTrue(content.Content!.Contains("hx-swap-oob")); + Assert.IsTrue(content.Content!.Contains("\"hours\":6")); + } + + [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 content = (ContentResult)result; + Assert.IsTrue(content.Content!.Contains("\"hours\":24")); + } + + [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 OnGetTrendsAsyncShouldReturnOobContentWithTrendsData() + { + 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 content = (ContentResult)result; + Assert.AreEqual("text/html", content.ContentType); + Assert.IsTrue(content.Content!.Contains("responseTimeTrendsChartData")); + Assert.IsTrue(content.Content!.Contains("hx-swap-oob")); + Assert.IsTrue(content.Content!.Contains("\"hours\":3")); + } + + [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 content = (ContentResult)result; + Assert.IsTrue(content.Content!.Contains("\"hours\":24")); + } } diff --git a/SAMA.Web/Pages/Dashboard/Index.cshtml b/SAMA.Web/Pages/Dashboard/Index.cshtml index 2412e34..b4c62d8 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"; @@ -149,13 +150,27 @@ else -
- -
+ @if (!Request.Headers.ContainsKey("HX-Request")) @* Prevents clobbering the chart on HTMX requests *@ + { +
+ +
+ } +
-
- -
+ @if (!Request.Headers.ContainsKey("HX-Request")) @* Prevents clobbering the chart on HTMX requests *@ + { +
+ +
+ } +
diff --git a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs index 5fce159..da943df 100644 --- a/SAMA.Web/Pages/Dashboard/Index.cshtml.cs +++ b/SAMA.Web/Pages/Dashboard/Index.cshtml.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Mvc; using SAMA.Web.Authorization; using SAMA.Web.Models; @@ -15,14 +16,15 @@ public class IndexModel( DashboardCacheService _cacheService) : WorkspacePageModel(_workspaceQueryService) { + private static readonly JsonSerializerOptions CamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + public IList Checks { get; set; } = []; 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; } @@ -48,15 +50,41 @@ public async Task OnGetAsync(Guid workspaceId, int? timelineHours Checks = workspaceData.Checks; RecentAlerts = workspaceData.RecentAlerts; - var workspace = await _workspaceQueryService.GetWorkspaceDetailsAsync(WorkspaceId); - if (workspace != null) + var dashboardMessage = await _workspaceQueryService.GetDashboardMessageAsync(WorkspaceId); + DashboardMessageHtml = _markdownService.RenderToHtml(dashboardMessage); + + return Page(); + } + + public async Task OnGetTimelineAsync(Guid workspaceId, int? timelineHours) + { + var result = await LoadWorkspaceContextAsync(workspaceId, "Dashboard"); + if (result != null) { - DashboardMessageHtml = _markdownService.RenderToHtml(workspace.DashboardMessage); + return result; } - IncidentTimeline = await _cacheService.GetTimelineAsync(WorkspaceId, TimelineHours); - ResponseTimeTrends = await _cacheService.GetTrendsAsync(WorkspaceId, TrendsHours); + var hours = timelineHours ?? 24; + var timeline = await _cacheService.GetTimelineAsync(WorkspaceId, hours); + var json = JsonSerializer.Serialize(timeline, CamelCaseOptions); + return Content( + $"""""", + "text/html"); + } - return Page(); + 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); + var json = JsonSerializer.Serialize(trends, CamelCaseOptions); + return Content( + $"""""", + "text/html"); } } diff --git a/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml b/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml index a5dc854..4c27859 100644 --- a/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml +++ b/SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml @@ -13,12 +13,12 @@
-
+
Loading...
-
+
No timeline data available for the selected time period.
@@ -33,31 +33,34 @@ (function() { function getStatusColor(increment) { const total = increment.upCount + increment.warnCount + increment.downCount; - + if (total === 0) { return 'rgb(108, 117, 125)'; // Gray for no data } - + if (increment.downCount > 0) { return 'rgb(220, 53, 69)'; // Red for any down } - + if (increment.warnCount > 0) { return 'rgb(255, 193, 7)'; // Amber for any warning } - + return 'rgb(25, 135, 84)'; // Green for all up } function initializeIncidentTimelineChart() { const canvas = document.getElementById('incidentTimelineChartCanvas'); const noDataDiv = document.getElementById('timelineChartNoData'); + const loadingDiv = document.getElementById('timelineChartLoading'); const dataScript = document.getElementById('incidentTimelineChartData'); if (!canvas || !dataScript) { return; } + loadingDiv.classList.add('d-none'); + const timelineData = JSON.parse(dataScript.textContent); if (!timelineData || timelineData.increments.length === 0) { @@ -75,7 +78,7 @@ const datasets = timelineData.increments.map((increment) => { const incrementDuration = new Date(increment.endTime).getTime() - new Date(increment.startTime).getTime(); - + return { label: '', data: [incrementDuration], @@ -97,7 +100,7 @@ const startMs = new Date(timelineData.startTime).getTime(); const timeMs = startMs + value; const date = new Date(timeMs); - + if (timelineData.hours <= 24) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else { @@ -178,15 +181,15 @@ label: function(context) { const increment = context.dataset.incrementData; const total = increment.upCount + increment.warnCount + increment.downCount; - + const lines = []; - + if (total > 0) { lines.push(`${increment.upCount} Up, ${increment.warnCount} Degraded, ${increment.downCount} Down`); } else { lines.push('No data'); } - + return lines; }, afterLabel: function(context) { @@ -222,7 +225,7 @@ const startMs = new Date(timelineData.startTime).getTime(); const timeMs = startMs + value; const date = new Date(timeMs); - + if (timelineData.hours <= 24) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else { @@ -265,12 +268,10 @@ } if (typeof Chart !== 'undefined') { - initializeIncidentTimelineChart(); setupIncidentTimelineButtonHandlers(); setupHtmxListener(); } else { document.addEventListener('DOMContentLoaded', function() { - initializeIncidentTimelineChart(); setupIncidentTimelineButtonHandlers(); setupHtmxListener(); }); diff --git a/SAMA.Web/Pages/Dashboard/_ResponseTimeTrendsChart.cshtml b/SAMA.Web/Pages/Dashboard/_ResponseTimeTrendsChart.cshtml index e385fa9..48199c5 100644 --- a/SAMA.Web/Pages/Dashboard/_ResponseTimeTrendsChart.cshtml +++ b/SAMA.Web/Pages/Dashboard/_ResponseTimeTrendsChart.cshtml @@ -13,12 +13,12 @@
-
+
Loading...
-
+
No response time data available for the selected time period.
@@ -47,12 +47,15 @@ function initializeResponseTimeTrendsChart() { const canvas = document.getElementById('responseTimeTrendsChartCanvas'); const noDataDiv = document.getElementById('trendsChartNoData'); + const loadingDiv = document.getElementById('trendsChartLoading'); const dataScript = document.getElementById('responseTimeTrendsChartData'); if (!canvas || !dataScript) { return; } + loadingDiv.classList.add('d-none'); + const trendsData = JSON.parse(dataScript.textContent); if (!trendsData || trendsData.series.length === 0) { @@ -88,12 +91,12 @@ if (window.responseTimeTrendsChart) { // Update existing datasets in place to preserve metadata (including hidden state) const existingDatasets = window.responseTimeTrendsChart.data.datasets; - + // Remove extra datasets if we have fewer series now while (existingDatasets.length > datasets.length) { existingDatasets.pop(); } - + // Update or add datasets datasets.forEach((newDataset, index) => { if (index < existingDatasets.length) { @@ -114,7 +117,7 @@ window.responseTimeTrendsChart.options.plugins.tooltip.callbacks.label = function(context) { const checkName = trendsData.series[context.datasetIndex].checkName; const responseTime = context.parsed.y; - return responseTime !== null + return responseTime !== null ? `${checkName}: ${responseTime} ms` : `${checkName}: No data`; }; @@ -160,7 +163,7 @@ const trendsData = JSON.parse(dataScript.textContent); const checkName = trendsData.series[context.datasetIndex].checkName; const responseTime = context.parsed.y; - return responseTime !== null + return responseTime !== null ? `${checkName}: ${responseTime} ms` : `${checkName}: No data`; } @@ -218,12 +221,10 @@ } if (typeof Chart !== 'undefined') { - initializeResponseTimeTrendsChart(); setupResponseTimeTrendsButtonHandlers(); setupHtmxListener(); } else { document.addEventListener('DOMContentLoaded', function() { - initializeResponseTimeTrendsChart(); setupResponseTimeTrendsButtonHandlers(); setupHtmxListener(); }); diff --git a/SAMA.Web/Services/Queries/CheckQueryService.cs b/SAMA.Web/Services/Queries/CheckQueryService.cs index e8cb133..64f3ede 100644 --- a/SAMA.Web/Services/Queries/CheckQueryService.cs +++ b/SAMA.Web/Services/Queries/CheckQueryService.cs @@ -351,15 +351,18 @@ 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); - 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; } @@ -393,8 +396,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 +500,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)