diff --git a/HealthMonitoring/HealthMonitoring.Tests/Reporting/ObservationMetricsTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ObservationMetricsTests.cs new file mode 100644 index 0000000..23bcd24 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ObservationMetricsTests.cs @@ -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); + } +} diff --git a/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServiceDetailLoadStateTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServiceDetailLoadStateTests.cs new file mode 100644 index 0000000..9e989a4 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServiceDetailLoadStateTests.cs @@ -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); + } +} diff --git a/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor b/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor index 56cd831..2b497e6 100644 --- a/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor +++ b/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor @@ -1,16 +1,25 @@ @page "/services/{ServiceId}" @inject IDashboardQueryService Dashboard +@inject ILogger Logger -@(_model?.Service.Name ?? "Service") · Operational Support -@if (_model is null) +@(_loadState.Model?.Service.Name ?? "Service") · Operational Support +@if (_loadState.IsLoading) +{ +

Loading service details…

+} +else if (_loadState.ErrorMessage is not null) +{ +

Unable to load service details. Please try again later.

+} +else if (_loadState.IsNotFound) {

Service not found.

} else { -

SERVICE DETAIL · @_model.Service.Environment

@_model.Service.Name

@MonitorDescription

Version: @(string.IsNullOrWhiteSpace(_model.Service.Version) ? "not reported" : _model.Service.Version)

Manage dependency links Back to overview
+

SERVICE DETAIL · @_model!.Service.Environment

@_model.Service.Name

@MonitorDescription

Version: @(string.IsNullOrWhiteSpace(_model.Service.Version) ? "not reported" : _model.Service.Version)

Manage dependency links Back to overview
Last checked @( _model.Summary.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "never")
-
+

Status timeline

@foreach (var observation in TimelineObservationSelector.Select(_model.Observations)) { }

Dependency details

@if (_model.Checks.Count == 0) {

No dependency details have been returned.

} else {
CheckStatusDurationDetails
@foreach (var check in LatestChecks) { var link = _model.DependencyLinks.FirstOrDefault(item => string.Equals(item.DependencyName.Trim(), check.Result.Name.Trim(), StringComparison.OrdinalIgnoreCase));
@if (link is null) { @check.Result.Name } else { @check.Result.Name ↗ }
@check.Result.Duration.TotalMilliseconds.ToString("0") ms
@(check.Result.Description ?? check.Result.Exception ?? "No detail")
}
}
Latest raw health response
@(_model.Observations.FirstOrDefault()?.RawResponseJson ?? "No response recorded.")
@@ -19,6 +28,7 @@ else @code { [Parameter] public string ServiceId { get; set; } = string.Empty; private ServiceDetailModel? _model; + private ServiceDetailLoadState _loadState = ServiceDetailLoadState.Loading; private IEnumerable LatestChecks => _model?.Observations .SelectMany(observation => observation.Checks.Select(check => new ObservedCheck(check, observation.ObservedAtUtc))) .GroupBy(item => item.Result.Name, StringComparer.OrdinalIgnoreCase) @@ -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); } diff --git a/HealthMonitoring/HealthMonitoring/Reporting/DashboardModels.cs b/HealthMonitoring/HealthMonitoring/Reporting/DashboardModels.cs index 9095be1..0c040f2 100644 --- a/HealthMonitoring/HealthMonitoring/Reporting/DashboardModels.cs +++ b/HealthMonitoring/HealthMonitoring/Reporting/DashboardModels.cs @@ -5,4 +5,4 @@ namespace HealthMonitoring.Reporting; public sealed record DashboardOverview(IReadOnlyList 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 Observations, IReadOnlyList Incidents, IReadOnlyList Checks, IReadOnlyList DependencyLinks); +public sealed record ServiceDetailModel(MonitoredService Service, ServiceDashboardRow Summary, int ObservationCount, IReadOnlyList Observations, IReadOnlyList Incidents, IReadOnlyList Checks, IReadOnlyList DependencyLinks); diff --git a/HealthMonitoring/HealthMonitoring/Reporting/DashboardQueryService.cs b/HealthMonitoring/HealthMonitoring/Reporting/DashboardQueryService.cs index 7813278..a9ac476 100644 --- a/HealthMonitoring/HealthMonitoring/Reporting/DashboardQueryService.cs +++ b/HealthMonitoring/HealthMonitoring/Reporting/DashboardQueryService.cs @@ -35,7 +35,12 @@ public async Task 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(); @@ -43,7 +48,8 @@ public async Task GetOverviewAsync(TimeSpan range, Cancellati .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 observations) diff --git a/HealthMonitoring/HealthMonitoring/Reporting/ObservationMetrics.cs b/HealthMonitoring/HealthMonitoring/Reporting/ObservationMetrics.cs new file mode 100644 index 0000000..d363713 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Reporting/ObservationMetrics.cs @@ -0,0 +1,6 @@ +namespace HealthMonitoring.Reporting; + +public sealed record ObservationMetrics(int TotalCount, int HealthyCount) +{ + public double UptimePercent => TotalCount == 0 ? 0 : HealthyCount * 100d / TotalCount; +} diff --git a/HealthMonitoring/HealthMonitoring/Reporting/ServiceDetailLoadState.cs b/HealthMonitoring/HealthMonitoring/Reporting/ServiceDetailLoadState.cs new file mode 100644 index 0000000..04598e2 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Reporting/ServiceDetailLoadState.cs @@ -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); +}