Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions SAMA.Tests.Unit/Web/Pages/Dashboard/IndexModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public void Setup()
_pageModel = new IndexModel(_mockWorkspaceQuery, _mockGlobalSettings, _markdownService, _cacheService);
PageModelTestHelpers.ConfigurePageModel(_pageModel);

_mockWorkspaceQuery.GetWorkspaceDetailsAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(new WorkspaceDetailsViewModel());
_mockWorkspaceQuery.GetDashboardMessageAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((string?)null);
}

private void PrePopulateCache(Guid workspaceId, List<CheckListItemViewModel>? checks = null, List<RecentAlertViewModel>? alerts = null)
Expand Down Expand Up @@ -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<Workspace?>(null));

var result = await _pageModel.OnGetTimelineAsync(workspaceId, null);

Assert.IsInstanceOfType<NotFoundResult>(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?>(workspace));
_cacheService.SetTimeline(workspaceId, 6, new WorkspaceIncidentTimelineViewModel { Hours = 6 });

var result = await _pageModel.OnGetTimelineAsync(workspaceId, 6);

Assert.IsInstanceOfType<ContentResult>(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?>(workspace));
_cacheService.SetTimeline(workspaceId, 24, new WorkspaceIncidentTimelineViewModel { Hours = 24 });

var result = await _pageModel.OnGetTimelineAsync(workspaceId, null);

Assert.IsInstanceOfType<ContentResult>(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<Workspace?>(null));

var result = await _pageModel.OnGetTrendsAsync(workspaceId, null);

Assert.IsInstanceOfType<NotFoundResult>(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?>(workspace));
_cacheService.SetTrends(workspaceId, 3, new WorkspaceResponseTimeTrendsViewModel { Hours = 3 });

var result = await _pageModel.OnGetTrendsAsync(workspaceId, 3);

Assert.IsInstanceOfType<ContentResult>(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?>(workspace));
_cacheService.SetTrends(workspaceId, 24, new WorkspaceResponseTimeTrendsViewModel { Hours = 24 });

var result = await _pageModel.OnGetTrendsAsync(workspaceId, null);

Assert.IsInstanceOfType<ContentResult>(result);
var content = (ContentResult)result;
Assert.IsTrue(content.Content!.Contains("\"hours\":24"));
}
}
27 changes: 21 additions & 6 deletions SAMA.Web/Pages/Dashboard/Index.cshtml
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -149,13 +150,27 @@ else
</div>
</div>

<div class="mb-4">
<partial name="_IncidentTimelineChart" model="Model.IncidentTimeline" />
</div>
@if (!Request.Headers.ContainsKey("HX-Request")) @* Prevents clobbering the chart on HTMX requests *@
{
<div class="mb-4">
<partial name="_IncidentTimelineChart" model="new WorkspaceIncidentTimelineViewModel { Hours = Model.TimelineHours }" />
</div>
}
<div class="d-none"
hx-get="/Dashboard/Index?handler=Timeline&workspaceId=@Model.WorkspaceId&timelineHours=@Model.TimelineHours"
hx-trigger="load, every @(Model.RefreshIntervalSeconds)s"
hx-swap="none"></div>
Comment on lines +159 to +162

<div class="mb-4">
<partial name="_ResponseTimeTrendsChart" model="Model.ResponseTimeTrends" />
</div>
@if (!Request.Headers.ContainsKey("HX-Request")) @* Prevents clobbering the chart on HTMX requests *@
{
<div class="mb-4">
<partial name="_ResponseTimeTrendsChart" model="new WorkspaceResponseTimeTrendsViewModel { Hours = Model.TrendsHours }" />
</div>
}
<div class="d-none"
hx-get="/Dashboard/Index?handler=Trends&workspaceId=@Model.WorkspaceId&trendsHours=@Model.TrendsHours"
hx-trigger="load, every @(Model.RefreshIntervalSeconds)s"
hx-swap="none"></div>
Comment on lines +170 to +173

<div class="row">
<div class="col-lg-8">
Expand Down
48 changes: 38 additions & 10 deletions SAMA.Web/Pages/Dashboard/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using SAMA.Web.Authorization;
using SAMA.Web.Models;
Expand All @@ -15,14 +16,15 @@ public class IndexModel(
DashboardCacheService _cacheService)
: WorkspacePageModel(_workspaceQueryService)
{
private static readonly JsonSerializerOptions CamelCaseOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

public IList<CheckListItemViewModel> Checks { get; set; } = [];

public IList<RecentAlertViewModel> 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; }
Expand All @@ -48,15 +50,41 @@ public async Task<IActionResult> 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<IActionResult> 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(
$"""<script id="incidentTimelineChartData" type="application/json" hx-swap-oob="true">{json}</script>""",
"text/html");
}
Comment thread
arktronic-sep marked this conversation as resolved.

return Page();
public async Task<IActionResult> 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(
$"""<script id="responseTimeTrendsChartData" type="application/json" hx-swap-oob="true">{json}</script>""",
"text/html");
}
Comment thread
arktronic-sep marked this conversation as resolved.
}
29 changes: 15 additions & 14 deletions SAMA.Web/Pages/Dashboard/_IncidentTimelineChart.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
<div class="card-body">
<div style="position: relative; min-height: 120px;">
<canvas id="incidentTimelineChartCanvas" style="max-height: 120px;"></canvas>
<div id="timelineChartLoading" class="text-center py-5 d-none" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
<div id="timelineChartLoading" class="text-center py-5 @(Model.Increments.Count == 0 ? "" : "d-none")" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="timelineChartNoData" class="text-center py-5 text-muted @(Model.Increments.Count == 0 ? "" : "d-none")" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
<div id="timelineChartNoData" class="text-center py-5 text-muted d-none" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
No timeline data available for the selected time period.
</div>
</div>
Expand All @@ -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) {
Expand All @@ -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],
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -265,12 +268,10 @@
}

if (typeof Chart !== 'undefined') {
initializeIncidentTimelineChart();
setupIncidentTimelineButtonHandlers();
setupHtmxListener();
} else {
document.addEventListener('DOMContentLoaded', function() {
initializeIncidentTimelineChart();
setupIncidentTimelineButtonHandlers();
setupHtmxListener();
});
Expand Down
Loading
Loading