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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using HealthMonitoring.Domain;
using HealthMonitoring.Reporting;

namespace HealthMonitoring.Tests.Reporting;

public sealed class ObservationMetricsTests
{
[Fact]
public void Uptime_percent_uses_all_observations_not_the_display_window()
{
var metrics = new ObservationMetrics(250, 225);

Assert.Equal(250, metrics.TotalCount);
Assert.Equal(90d, metrics.UptimePercent);
}

[Fact]
public void Uptime_percent_is_zero_when_no_observations_exist()
{
var metrics = new ObservationMetrics(0, 0);

Assert.Equal(0d, metrics.UptimePercent);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using HealthMonitoring.Reporting;

namespace HealthMonitoring.Tests.Reporting;

public sealed class ServiceDetailLoadStateTests
{
[Fact]
public void Loading_state_is_not_reported_as_not_found()
{
var state = ServiceDetailLoadState.Loading;

Assert.True(state.IsLoading);
Assert.False(state.IsNotFound);
Assert.Null(state.ErrorMessage);
}

[Fact]
public void Missing_state_is_reported_as_not_found()
{
var state = ServiceDetailLoadState.NotFound;

Assert.False(state.IsLoading);
Assert.True(state.IsNotFound);
Assert.Null(state.ErrorMessage);
}

[Fact]
public void Failed_state_exposes_error_without_being_reported_as_not_found()
{
var state = ServiceDetailLoadState.Failed("Database query failed.");

Assert.False(state.IsLoading);
Assert.False(state.IsNotFound);
Assert.Equal("Database query failed.", state.ErrorMessage);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
@page "/services/{ServiceId}"
@inject IDashboardQueryService Dashboard
@inject ILogger<ServiceDetail> Logger

<PageTitle>@(_model?.Service.Name ?? "Service") · Operational Support</PageTitle>
@if (_model is null)
<PageTitle>@(_loadState.Model?.Service.Name ?? "Service") · Operational Support</PageTitle>
@if (_loadState.IsLoading)
{
<p class="panel">Loading service details…</p>
}
else if (_loadState.ErrorMessage is not null)
{
<p class="panel">Unable to load service details. Please try again later.</p>
}
else if (_loadState.IsNotFound)
{
<p class="panel">Service not found.</p>
}
else
{
<div class="page-heading"><div><p class="eyebrow">SERVICE DETAIL · @_model.Service.Environment</p><h1>@_model.Service.Name</h1><p class="muted">@MonitorDescription</p><p class="service-version-detail">Version: <strong>@(string.IsNullOrWhiteSpace(_model.Service.Version) ? "not reported" : _model.Service.Version)</strong></p></div><div><a class="button secondary" href="@($"/services/{ServiceId}/dependencies")">Manage dependency links</a> <a class="button secondary" href="/">Back to overview</a></div></div>
<div class="page-heading"><div><p class="eyebrow">SERVICE DETAIL · @_model!.Service.Environment</p><h1>@_model.Service.Name</h1><p class="muted">@MonitorDescription</p><p class="service-version-detail">Version: <strong>@(string.IsNullOrWhiteSpace(_model.Service.Version) ? "not reported" : _model.Service.Version)</strong></p></div><div><a class="button secondary" href="@($"/services/{ServiceId}/dependencies")">Manage dependency links</a> <a class="button secondary" href="/">Back to overview</a></div></div>
<div class="detail-status"><StatusBadge Status="@_model.Summary.Status" /> <span>Last checked @( _model.Summary.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "never")</span></div>
<div class="stat-grid three"><StatCard Label="Uptime (24 hours)" Value="@UptimeText" Class="healthy" /><StatCard Label="Observations" Value="@_model.Observations.Count" Class="neutral" /><StatCard Label="Response" Value="@ResponseText" Class="neutral" /></div>
<div class="stat-grid three"><StatCard Label="Uptime (24 hours)" Value="@UptimeText" Class="healthy" /><StatCard Label="Observations" Value="@_model.ObservationCount" Class="neutral" /><StatCard Label="Response" Value="@ResponseText" Class="neutral" /></div>
<section class="panel"><h2>Status timeline</h2><div class="timeline">@foreach (var observation in TimelineObservationSelector.Select(_model.Observations)) { <span class="timeline-segment @observation.Status.ToString().ToLowerInvariant()" title="@observation.ObservedAtUtc.ToLocalTime()"></span> }</div></section>
<section class="panel"><h2>Dependency details</h2>@if (_model.Checks.Count == 0) { <p class="muted">No dependency details have been returned.</p> } else { <div class="check-table"><div class="table-heading"><span>Check</span><span>Status</span><span>Duration</span><span>Details</span></div>@foreach (var check in LatestChecks) { var link = _model.DependencyLinks.FirstOrDefault(item => string.Equals(item.DependencyName.Trim(), check.Result.Name.Trim(), StringComparison.OrdinalIgnoreCase)); <div class="service-row"><div>@if (link is null) { <strong>@check.Result.Name</strong> } else { <a class="dependency-link" href="@($"/services/{link.TargetServiceId}")"><strong>@check.Result.Name</strong> ↗</a> }</div><div><StatusBadge Status="@check.Result.Status" /></div><div>@check.Result.Duration.TotalMilliseconds.ToString("0") ms</div><div>@(check.Result.Description ?? check.Result.Exception ?? "No detail")</div></div>}</div> }</section>
<details class="panel raw"><summary>Latest raw health response</summary><pre>@(_model.Observations.FirstOrDefault()?.RawResponseJson ?? "No response recorded.")</pre></details>
Expand All @@ -19,6 +28,7 @@ else
@code {
[Parameter] public string ServiceId { get; set; } = string.Empty;
private ServiceDetailModel? _model;
private ServiceDetailLoadState _loadState = ServiceDetailLoadState.Loading;
private IEnumerable<ObservedCheck> LatestChecks => _model?.Observations
.SelectMany(observation => observation.Checks.Select(check => new ObservedCheck(check, observation.ObservedAtUtc)))
.GroupBy(item => item.Result.Name, StringComparer.OrdinalIgnoreCase)
Expand All @@ -32,7 +42,24 @@ else
MonitorType.SqlServer => "SQL Server monitor",
_ => _model?.Service.HealthUrl?.ToString() ?? "HTTP health monitor"
} ?? "Service monitor";
protected override async Task OnParametersSetAsync() => _model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None);
protected override async Task OnParametersSetAsync()
{
_loadState = ServiceDetailLoadState.Loading;
_model = null;

try
{
_model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None);
_loadState = _model is null
? ServiceDetailLoadState.NotFound
: ServiceDetailLoadState.Loaded(_model);
}
catch (Exception exception)
{
Logger.LogError(exception, "Unable to load service detail for {ServiceId}.", ServiceId);
_loadState = ServiceDetailLoadState.Failed("Service details could not be loaded.");
}
}

private sealed record ObservedCheck(HealthCheckResult Result, DateTimeOffset ObservedAtUtc);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ namespace HealthMonitoring.Reporting;
public sealed record DashboardOverview(IReadOnlyList<ServiceDashboardRow> Services, int Healthy, int Degraded, int Unhealthy, int OpenIncidents);
public sealed record ServiceDashboardRow(string ServiceId, string Name, string Environment, string? Version, HealthStatus Status, TimeSpan? ResponseDuration, double UptimePercent, DateTimeOffset? LastChecked, string? LastError);
public sealed record ResolvedDependencyLink(string DependencyName, string TargetServiceId, string TargetServiceName);
public sealed record ServiceDetailModel(MonitoredService Service, ServiceDashboardRow Summary, IReadOnlyList<HealthObservation> Observations, IReadOnlyList<ServiceIncident> Incidents, IReadOnlyList<HealthCheckResult> Checks, IReadOnlyList<ResolvedDependencyLink> DependencyLinks);
public sealed record ServiceDetailModel(MonitoredService Service, ServiceDashboardRow Summary, int ObservationCount, IReadOnlyList<HealthObservation> Observations, IReadOnlyList<ServiceIncident> Incidents, IReadOnlyList<HealthCheckResult> Checks, IReadOnlyList<ResolvedDependencyLink> DependencyLinks);
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,21 @@ public async Task<DashboardOverview> GetOverviewAsync(TimeSpan range, Cancellati
var service = await dbContext.MonitoredServices.AsNoTracking().SingleOrDefaultAsync(item => item.ServiceId == serviceId && item.ArchivedAtUtc == null, cancellationToken);
if (service is null) return null;
var from = DateTimeOffset.UtcNow.Subtract(range);
var observations = await dbContext.HealthObservations.AsNoTracking().Include(item => item.Checks).Where(item => item.MonitoredServiceId == service.Id && item.ObservedAtUtc >= from).OrderByDescending(item => item.ObservedAtUtc).ToListAsync(cancellationToken);
var observationQuery = dbContext.HealthObservations.AsNoTracking().Where(item => item.MonitoredServiceId == service.Id && item.ObservedAtUtc >= from);
var observationMetrics = await observationQuery
.GroupBy(_ => 1)
.Select(group => new ObservationMetrics(group.Count(), group.Count(item => item.Status == HealthStatus.Healthy)))
.SingleOrDefaultAsync(cancellationToken) ?? new ObservationMetrics(0, 0);
var observations = await observationQuery.AsSplitQuery().Include(item => item.Checks).OrderByDescending(item => item.ObservedAtUtc).Take(TimelineObservationSelector.MaximumSegments).ToListAsync(cancellationToken);
var incidents = await dbContext.ServiceIncidents.AsNoTracking().Where(item => item.MonitoredServiceId == service.Id && (item.EndedAtUtc == null || item.EndedAtUtc >= from)).OrderByDescending(item => item.StartedAtUtc).ToListAsync(cancellationToken);
var snapshot = await dbContext.ServiceStatusSnapshots.AsNoTracking().SingleOrDefaultAsync(item => item.MonitoredServiceId == service.Id, cancellationToken);
var checks = observations.SelectMany(item => item.Checks).ToArray();
var dependencyLinks = await dbContext.ServiceDependencyLinks.AsNoTracking()
.Where(link => link.MonitoredServiceId == service.Id)
.Join(dbContext.MonitoredServices.AsNoTracking(), link => link.TargetMonitoredServiceId, target => target.Id, (link, target) => new ResolvedDependencyLink(link.DependencyName, target.ServiceId, target.Name))
.ToListAsync(cancellationToken);
return new ServiceDetailModel(service, ToRow(service, snapshot, observations), observations, incidents, checks, dependencyLinks);
var summary = ToRow(service, snapshot, observations) with { UptimePercent = observationMetrics.UptimePercent };
return new ServiceDetailModel(service, summary, observationMetrics.TotalCount, observations, incidents, checks, dependencyLinks);
}

private static ServiceDashboardRow ToRow(MonitoredService service, ServiceStatusSnapshot? snapshot, IReadOnlyList<HealthObservation> observations)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace HealthMonitoring.Reporting;

public sealed record ObservationMetrics(int TotalCount, int HealthyCount)
{
public double UptimePercent => TotalCount == 0 ? 0 : HealthyCount * 100d / TotalCount;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace HealthMonitoring.Reporting;

public sealed record ServiceDetailLoadState(bool IsLoading, ServiceDetailModel? Model, string? ErrorMessage)
{
public bool IsNotFound => !IsLoading && Model is null && ErrorMessage is null;

public static ServiceDetailLoadState Loading { get; } = new(true, null, null);
public static ServiceDetailLoadState NotFound { get; } = new(false, null, null);

public static ServiceDetailLoadState Loaded(ServiceDetailModel model) => new(false, model, null);

public static ServiceDetailLoadState Failed(string errorMessage) => new(false, null, errorMessage);
}
Loading