diff --git a/playground/HealthChecks/HealthChecksSandbox.AppHost/Program.cs b/playground/HealthChecks/HealthChecksSandbox.AppHost/Program.cs index 4e108d82876..2912470dd05 100644 --- a/playground/HealthChecks/HealthChecksSandbox.AppHost/Program.cs +++ b/playground/HealthChecks/HealthChecksSandbox.AppHost/Program.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; var builder = DistributedApplication.CreateBuilder(args); @@ -9,8 +10,8 @@ builder.Services.TryAddLifecycleHook(); AddTestResource("healthy", HealthStatus.Healthy, "I'm fine, thanks for asking."); -AddTestResource("unhealthy", HealthStatus.Unhealthy, "I can't do that, Dave.", exception: GetException("Feeling unhealthy.")); -AddTestResource("degraded", HealthStatus.Degraded, "Had better days.", exception: GetException("Feeling degraded.")); +AddTestResource("unhealthy", HealthStatus.Unhealthy, "I can't do that, Dave.", exceptionMessage: "Feeling unhealthy."); +AddTestResource("degraded", HealthStatus.Degraded, "Had better days.", exceptionMessage: "Feeling degraded."); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging @@ -24,30 +25,37 @@ builder.Build().Run(); -static string GetException(string message) +void AddTestResource(string name, HealthStatus status, string? description = null, string? exceptionMessage = null) { - try - { - throw new InvalidOperationException(message); - } - catch (InvalidOperationException ex) - { - return ex.ToString(); - } -} + var hasHealthyAfterFirstRunCheckRun = false; + builder.Services.AddHealthChecks() + .AddCheck( + $"{name}_check", + () => new HealthCheckResult(status, description, new InvalidOperationException(exceptionMessage)) + ) + .AddCheck($"{name}_resource_healthy_after_first_run_check", () => + { + if (!hasHealthyAfterFirstRunCheckRun) + { + hasHealthyAfterFirstRunCheckRun = true; + return new HealthCheckResult(HealthStatus.Unhealthy, "Initial failure state."); + } -IResourceBuilder AddTestResource(string name, HealthStatus status, string? description = null, string? exception = null) -{ - return builder + return new HealthCheckResult(HealthStatus.Healthy, "Healthy beginning second health check run."); + }); + + builder .AddResource(new TestResource(name)) + .WithHealthCheck($"{name}_check") + .WithHealthCheck($"{name}_resource_healthy_after_first_run_check") .WithInitialState(new() { ResourceType = "Test Resource", State = "Starting", Properties = [], - HealthReports = [new HealthReportSnapshot($"{name}_check", status, description, exception)] }) .ExcludeFromManifest(); + return; } internal sealed class TestResource(string name) : Resource(name); diff --git a/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs index 0508a2c49fc..57210c95b7f 100644 --- a/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs @@ -62,12 +62,6 @@ internal static ResourceStateViewModel GetStateViewModel(ResourceViewModel resou icon = new Icons.Filled.Size16.Circle(); color = Color.Info; } - else if (resource.HealthStatus is null) - { - // If we are waiting for a health check, show a progress bar and consider the resource unhealthy - icon = new Icons.Filled.Size16.CheckmarkCircleWarning(); - color = Color.Warning; - } else if (resource.HealthStatus is not HealthStatus.Healthy) { icon = new Icons.Filled.Size16.CheckmarkCircleWarning(); diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index beb1a3a0f99..db8bd0ff390 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -20,6 +20,9 @@ namespace Aspire.Dashboard.Model; [DebuggerDisplay("Name = {Name}, ResourceType = {ResourceType}, State = {State}, Properties = {Properties.Count}")] public sealed class ResourceViewModel { + private readonly ImmutableArray _healthReports = []; + private readonly KnownResourceState? _knownState; + public required string Name { get; init; } public required string ResourceType { get; init; } public required string DisplayName { get; init; } @@ -35,9 +38,27 @@ public sealed class ResourceViewModel public required FrozenDictionary Properties { get; init; } public required ImmutableArray Commands { get; init; } /// The health status of the resource. indicates that health status is expected but not yet available. - public required HealthStatus? HealthStatus { get; init; } - public required ImmutableArray HealthReports { get; init; } - public KnownResourceState? KnownState { get; init; } + public HealthStatus? HealthStatus { get; private set; } + + public required ImmutableArray HealthReports + { + get => _healthReports; + init + { + _healthReports = value; + HealthStatus = ComputeHealthStatus(value, KnownState); + } + } + + public KnownResourceState? KnownState + { + get => _knownState; + init + { + _knownState = value; + HealthStatus = ComputeHealthStatus(_healthReports, value); + } + } internal bool MatchesFilter(string filter) { @@ -45,6 +66,22 @@ internal bool MatchesFilter(string filter) return Name.Contains(filter, StringComparisons.UserTextSearch); } + internal static HealthStatus? ComputeHealthStatus(ImmutableArray healthReports, KnownResourceState? state) + { + if (state != KnownResourceState.Running) + { + return null; + } + + return healthReports.Length == 0 + // If there are no health reports and the resource is running, assume it's healthy. + ? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy + // If there are health reports, the health status is the minimum of the health status of the reports. + // If any of the reports is null (first health check has not returned), the health status is unhealthy. + : healthReports.MinBy(r => r.HealthStatus)?.HealthStatus + ?? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy; + } + public static string GetResourceName(ResourceViewModel resource, IDictionary allResources) { var count = 0; diff --git a/src/Aspire.Dashboard/ResourceService/Partials.cs b/src/Aspire.Dashboard/ResourceService/Partials.cs index 5b94950db1e..d160abdb2af 100644 --- a/src/Aspire.Dashboard/ResourceService/Partials.cs +++ b/src/Aspire.Dashboard/ResourceService/Partials.cs @@ -49,7 +49,6 @@ public ResourceViewModel ToViewModel(BrowserTimeProvider timeProvider, IKnownPro KnownState = HasState ? Enum.TryParse(State, out KnownResourceState knownState) ? knownState : null : null, StateStyle = HasStateStyle ? StateStyle : null, Commands = GetCommands(), - HealthStatus = HasHealthStatus ? MapHealthStatus(HealthStatus) : null, HealthReports = HealthReports.Select(ToHealthReportViewModel).OrderBy(vm => vm.Name).ToImmutableArray(), }; } diff --git a/src/Aspire.Dashboard/Resources/Resources.Designer.cs b/src/Aspire.Dashboard/Resources/Resources.Designer.cs index 47755f80296..3b29a680e09 100644 --- a/src/Aspire.Dashboard/Resources/Resources.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Resources.Designer.cs @@ -285,7 +285,7 @@ public static string ResourcesDetailsExitCodeProperty { } /// - /// Looks up a localized string similar to Health State. + /// Looks up a localized string similar to Health state. /// public static string ResourcesDetailsHealthStateProperty { get { diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index 4dd31c54340..0be133f5eeb 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -233,7 +233,7 @@ View console logs - Health State + Health state Stop time @@ -262,4 +262,4 @@ No telemetry found for this resource. - \ No newline at end of file + diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf index f7b60cf377a..952b4022ab4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf @@ -128,8 +128,8 @@ - Health State - Stav + Health state + Stav diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf index 5ce9fa21b8a..1f3709f6347 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf index 99d1512f7bd..d3bf02411e7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf index 7a336a15540..5b5a85f37e6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf @@ -128,8 +128,8 @@ - Health State - État d’intégrité + Health state + État d’intégrité diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf index a6cdf9f6acb..b34689cd39a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf index c6fc8418ca9..3fc58cbacd8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf index 278a9a2888d..fb0e0c05ef6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf index bd9aa087e1e..a74486db9e9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf @@ -128,8 +128,8 @@ - Health State - Stan kondycji + Health state + Stan kondycji diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf index eb8d444f16d..6b99c7922f4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf index 801c91be17f..53def3af69f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf index 467ef73b1b5..395c855a97e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf @@ -128,8 +128,8 @@ - Health State - İşlevsel Durum + Health state + İşlevsel Durum diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf index 650255dd8cb..108d9673f7e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf @@ -128,8 +128,8 @@ - Health State - 运行状况状态 + Health state + 运行状况状态 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf index 44173c74a94..62477b788d3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf @@ -128,8 +128,8 @@ - Health State - Health State + Health state + Health state diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index 53b6463c514..31a60f2b928 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -12,6 +12,9 @@ namespace Aspire.Hosting.ApplicationModel; /// public sealed record CustomResourceSnapshot { + private readonly ImmutableArray _healthReports = []; + private readonly ResourceStateSnapshot? _state; + /// /// The type of the resource. /// @@ -40,7 +43,15 @@ public sealed record CustomResourceSnapshot /// /// Represents the state of the resource. /// - public ResourceStateSnapshot? State { get; init; } + public ResourceStateSnapshot? State + { + get => _state; + init + { + _state = value; + HealthStatus = ComputeHealthStatus(_healthReports, value?.Text); + } + } /// /// The exit code of the resource. @@ -52,11 +63,10 @@ public sealed record CustomResourceSnapshot /// /// /// - /// This value is derived from . If a resource is known to have a health check - /// and no reports exist, or if a resource does not have a health check, then this value is . + /// This value is derived from . /// /// - public HealthStatus? HealthStatus { get; init; } + public HealthStatus? HealthStatus { get; private set; } /// /// The health reports for this resource. @@ -65,7 +75,15 @@ public sealed record CustomResourceSnapshot /// May be zero or more. If there are no health reports, the resource is considered healthy /// so long as no heath checks are registered for the resource. /// - public ImmutableArray HealthReports { get; init; } = []; + public ImmutableArray HealthReports + { + get => _healthReports; + internal init + { + _healthReports = value; + HealthStatus = ComputeHealthStatus(value, State?.Text); + } + } /// /// The environment variables that should show up in the dashboard for this resource. @@ -86,6 +104,22 @@ public sealed record CustomResourceSnapshot /// The commands available in the dashboard for this resource. /// public ImmutableArray Commands { get; init; } = []; + + internal static HealthStatus? ComputeHealthStatus(ImmutableArray healthReports, string? state) + { + if (state != KnownResourceStates.Running) + { + return null; + } + + return healthReports.Length == 0 + // If there are no health reports and the resource is running, assume it's healthy. + ? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy + // If there are health reports, the health status is the minimum of the health status of the reports. + // If any of the reports is null (first health check has not returned), the health status is unhealthy. + : healthReports.MinBy(r => r.Status)?.Status + ?? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy; + } } /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index df3eeb0efc6..97aaac84e18 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -352,7 +352,6 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func $"{e.Name} = {e.Value}")), string.Join(", ", newState.Urls.Select(u => $"{u.Name} = {u.Url}")), string.Join(", ", newState.Properties.Select(p => $"{p.Name} = {p.Value}"))); } @@ -390,20 +388,6 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func - /// Update resource snapshot health status if the resource is running with no health checks. - /// - private static CustomResourceSnapshot UpdateHealthStatus(IResource resource, CustomResourceSnapshot previousState) - { - // A resource is also healthy if it has no health check annotations and is in the running state. - if (previousState.HealthStatus is not HealthStatus.Healthy && !resource.TryGetAnnotationsIncludingAncestorsOfType(out _) && previousState.State?.Text == KnownResourceStates.Running) - { - return previousState with { HealthStatus = HealthStatus.Healthy }; - } - - return previousState; - } - /// /// Use command annotations to update resource snapshot. /// diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index c38a4c8c1c3..06871521ec3 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Immutable; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; @@ -49,32 +48,9 @@ static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, string ExitCode = snapshot.ExitCode, State = snapshot.State?.Text, StateStyle = snapshot.State?.Style, - HealthStatus = snapshot.HealthStatus, - HealthReports = GetOrCreateHealthReports(), + HealthReports = snapshot.HealthReports, Commands = snapshot.Commands }; - - ImmutableArray GetOrCreateHealthReports() - { - if (!resource.TryGetAnnotationsIncludingAncestorsOfType(out var annotations)) - { - return snapshot.HealthReports; - } - - var enumeratedAnnotations = annotations.ToList(); - if (snapshot.HealthReports.Length == enumeratedAnnotations.Count) - { - return snapshot.HealthReports; - } - - var reportsByKey = snapshot.HealthReports.ToDictionary(report => report.Name); - foreach (var healthCheckAnnotation in enumeratedAnnotations.Where(annotation => !reportsByKey.ContainsKey(annotation.Key))) - { - reportsByKey.Add(healthCheckAnnotation.Key, new HealthReportSnapshot(healthCheckAnnotation.Key, null, null, null)); - } - - return [..reportsByKey.Values]; - } } var timestamp = DateTime.UtcNow; diff --git a/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs b/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs index fe73a321927..fcc55b0fd01 100644 --- a/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs +++ b/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs @@ -6,7 +6,6 @@ using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Aspire.Hosting.Dashboard; @@ -26,7 +25,6 @@ internal abstract class ResourceSnapshot public required ImmutableArray Environment { get; init; } public required ImmutableArray Volumes { get; init; } public required ImmutableArray Urls { get; init; } - public required HealthStatus? HealthStatus { get; init; } public required ImmutableArray HealthReports { get; init; } public required ImmutableArray Commands { get; init; } @@ -45,7 +43,7 @@ internal abstract class ResourceSnapshot yield return (KnownProperties.Resource.CreateTime, CreationTimeStamp is null ? Value.ForNull() : Value.ForString(CreationTimeStamp.Value.ToString("O")), IsSensitive: false); yield return (KnownProperties.Resource.StartTime, StartTimeStamp is null ? Value.ForNull() : Value.ForString(StartTimeStamp.Value.ToString("O")), IsSensitive: false); yield return (KnownProperties.Resource.StopTime, StopTimeStamp is null ? Value.ForNull() : Value.ForString(StopTimeStamp.Value.ToString("O")), IsSensitive: false); - yield return (KnownProperties.Resource.HealthState, HealthStatus is null ? Value.ForNull() : Value.ForString(HealthStatus.ToString()), IsSensitive: false); + yield return (KnownProperties.Resource.HealthState, CustomResourceSnapshot.ComputeHealthStatus(HealthReports, State) is not { } healthStatus ? Value.ForNull() : Value.ForString(healthStatus.ToString()), IsSensitive: false); foreach (var property in GetProperties()) { diff --git a/src/Aspire.Hosting/Dashboard/proto/Partials.cs b/src/Aspire.Hosting/Dashboard/proto/Partials.cs index a20a1d2295c..9ea268b571f 100644 --- a/src/Aspire.Hosting/Dashboard/proto/Partials.cs +++ b/src/Aspire.Hosting/Dashboard/proto/Partials.cs @@ -66,11 +66,6 @@ public static Resource FromSnapshot(ResourceSnapshot snapshot) resource.Commands.Add(new ResourceCommand { Name = command.Name, DisplayName = command.DisplayName, DisplayDescription = command.DisplayDescription ?? string.Empty, Parameter = ResourceSnapshot.ConvertToValue(command.Parameter), ConfirmationMessage = command.ConfirmationMessage ?? string.Empty, IconName = command.IconName ?? string.Empty, IconVariant = MapIconVariant(command.IconVariant), IsHighlighted = command.IsHighlighted, State = MapCommandState(command.State) }); } - if (snapshot.HealthStatus is not null) - { - resource.HealthStatus = MapHealthStatus(snapshot.HealthStatus.Value); - } - foreach (var report in snapshot.HealthReports) { var healthReport = new HealthReport { Key = report.Name, Description = report.Description ?? "", Exception = report.ExceptionText ?? "" }; diff --git a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto index d9213a1d521..fef720fc5f7 100644 --- a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto @@ -178,7 +178,7 @@ message ResourceProperty { // Models the full state of an resource (container, executable, project, etc) at a particular point in time. message Resource { - reserved 8, 9, 10; + reserved 8, 9, 10, 16; string name = 1; string resource_type = 2; string display_name = 3; @@ -208,8 +208,6 @@ message Resource { // The set of volumes mounted to the resource. Only applies to containers. repeated Volume volumes = 15; - // The aggregate health state of the resource. Generally reflects data from health_reports, but may differ. - optional HealthStatus health_status = 16; // Reports from health checks, about this resource. repeated HealthReport health_reports = 17; diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index b536ffba12d..3252b29b38a 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -227,7 +227,24 @@ private async Task PublishResourcesWithInitialStateAsync() // Publish the initial state of the resources that have a snapshot annotation. foreach (var resource in _model.Resources) { - await notificationService.PublishUpdateAsync(resource, s => s).ConfigureAwait(false); + await notificationService.PublishUpdateAsync(resource, s => + { + return s with + { + HealthReports = GetInitialHealthReports(resource) + }; + }).ConfigureAwait(false); + } + + static ImmutableArray GetInitialHealthReports(IResource resource) + { + if (!resource.TryGetAnnotationsIncludingAncestorsOfType(out var annotations)) + { + return []; + } + + var reports = annotations.Select(annotation => new HealthReportSnapshot(annotation.Key, null, null, null)); + return [..reports]; } } diff --git a/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs b/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs index 297fbe4dc3b..810994ce023 100644 --- a/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs +++ b/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs @@ -83,22 +83,48 @@ await eventing.PublishAsync( cancellationToken).ConfigureAwait(false); } - if (_latestEvents[resource.Name] is { } latestEvent && latestEvent.Snapshot.HealthStatus == report.Status) + var latestEvent = _latestEvents.GetValueOrDefault(resource.Name); + if (latestEvent is not null + && !latestEvent.Snapshot.HealthReports.Any(r => r.Status is null) // don't count events before we have health reports + && latestEvent.Snapshot.HealthStatus == report.Status) { await SlowDownMonitoringAsync(latestEvent, cancellationToken).ConfigureAwait(false); - continue; + + // If none of the health report statuses have changed, we should not update the resource health reports. + if (!ContainsAnyHealthReportChange(report, latestEvent.Snapshot.HealthReports)) + { + continue; + } + + static bool ContainsAnyHealthReportChange(HealthReport report, ImmutableArray latestHealthReportSnapshots) + { + var healthCheckNameToStatus = latestHealthReportSnapshots.ToDictionary(p => p.Name); + foreach (var (key, value) in report.Entries) + { + if (!healthCheckNameToStatus.TryGetValue(key, out var checkReportSnapshot)) + { + return true; + } + + if (checkReportSnapshot.Status != value.Status + || !StringComparers.HealthReportPropertyValue.Equals(checkReportSnapshot.Description, value.Description) + || !StringComparers.HealthReportPropertyValue.Equals(checkReportSnapshot.ExceptionText, value.Exception?.ToString())) + { + return true; + } + } + + return false; + } } await resourceNotificationService.PublishUpdateAsync(resource, s => { var healthReports = MergeHealthReports(s.HealthReports, report); - // Matches the logic in ASP.NET Core's private HealthReport.CalculateAggregateStatus - var healthStatus = healthReports.MinBy(r => r.Status)?.Status ?? s.HealthStatus; - return s with { - HealthStatus = healthStatus, + // HealthStatus is automatically re-computed after health reports change. HealthReports = healthReports }; }).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 5ba71003bd4..05884802ccd 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable Aspire.Hosting.ApplicationModel.ContainerLifetime.Session = 0 -> Aspire.Hosting.ApplicationModel.ContainerLifetime +Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? Aspire.Hosting.ApplicationModel.EndpointNameAttribute Aspire.Hosting.ApplicationModel.EndpointNameAttribute.EndpointNameAttribute() -> void Aspire.Hosting.ApplicationModel.HealthReportSnapshot @@ -46,10 +47,7 @@ Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.Name.get -> string! Aspire.Hosting.ApplicationModel.ContainerNameAnnotation.Name.set -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Commands.get -> System.Collections.Immutable.ImmutableArray Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.Commands.init -> void -Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.get -> Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? -Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthStatus.init -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthReports.get -> System.Collections.Immutable.ImmutableArray -Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.HealthReports.init -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StartTimeStamp.get -> System.DateTime? Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StartTimeStamp.init -> void Aspire.Hosting.ApplicationModel.CustomResourceSnapshot.StopTimeStamp.get -> System.DateTime? diff --git a/src/Shared/StringComparers.cs b/src/Shared/StringComparers.cs index b35809f8065..0308f9e1fc4 100644 --- a/src/Shared/StringComparers.cs +++ b/src/Shared/StringComparers.cs @@ -24,6 +24,7 @@ internal static class StringComparers public static StringComparer OtlpAttribute => StringComparer.Ordinal; public static StringComparer OtlpFieldValue => StringComparer.OrdinalIgnoreCase; public static StringComparer OtlpSpanId => StringComparer.Ordinal; + public static StringComparer HealthReportPropertyValue => StringComparer.Ordinal; } internal static class StringComparisons @@ -45,4 +46,5 @@ internal static class StringComparisons public static StringComparison OtlpAttribute => StringComparison.Ordinal; public static StringComparison OtlpFieldValue => StringComparison.OrdinalIgnoreCase; public static StringComparison OtlpSpanId => StringComparison.Ordinal; + public static StringComparison HealthReportPropertyValue => StringComparison.Ordinal; } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs index 57a616f7f8b..82291bbbdd9 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs @@ -36,7 +36,7 @@ public class ResourceStateViewModelTests /* state */ KnownResourceState.Running, null, "Healthy", null, /* expected output */ "Running", "CheckmarkCircle", Color.Success, "Running")] [InlineData( - /* state */ KnownResourceState.Running, null, null, null, + /* state */ KnownResourceState.Running, null, "", null, /* expected output */ $"Localized:{nameof(Columns.RunningAndUnhealthyResourceStateToolTip)}", "CheckmarkCircleWarning", Color.Warning, "Running (Unhealthy)")] [InlineData( /* state */ KnownResourceState.Running, null, "Unhealthy", null, @@ -61,7 +61,7 @@ public void ResourceViewModel_ReturnsCorrectIconAndTooltip( string expectedText) { // Arrange - HealthStatus? healthStatus = healthStatusString is null ? null : Enum.Parse(healthStatusString); + HealthStatus? healthStatus = string.IsNullOrEmpty(healthStatusString) ? null : Enum.Parse(healthStatusString); var propertiesDictionary = new Dictionary(); if (exitCode is not null) { @@ -70,7 +70,8 @@ public void ResourceViewModel_ReturnsCorrectIconAndTooltip( var resource = ModelTestHelpers.CreateResource( state: state, - healthStatus: healthStatus, + reportHealthStatus: healthStatus, + createNullHealthReport: healthStatusString == "", stateStyle: stateStyle, resourceType: ResourceType, properties: propertiesDictionary); diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs index bd88cb74ba6..7c6dc09380d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs @@ -1,12 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Aspire.Dashboard.Model; using Aspire.ResourceService.Proto.V1; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; using Xunit; - +using DiagnosticsHealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; namespace Aspire.Dashboard.Tests.Model; public sealed class ResourceViewModelTests @@ -14,6 +15,22 @@ public sealed class ResourceViewModelTests private static readonly DateTime s_dateTime = new(2000, 12, 30, 23, 59, 59, DateTimeKind.Utc); private static readonly BrowserTimeProvider s_timeProvider = new(NullLoggerFactory.Instance); + [Theory] + [InlineData(KnownResourceState.Starting, null, null)] + [InlineData(KnownResourceState.Starting, null, new string[]{})] + [InlineData(KnownResourceState.Starting, null, new string?[]{null})] + // we don't have a Running + HealthReports null case because that's not a valid state - by this point, we will have received the list of HealthReports + [InlineData(KnownResourceState.Running, DiagnosticsHealthStatus.Healthy, new string[]{})] + [InlineData(KnownResourceState.Running, DiagnosticsHealthStatus.Healthy, new string?[] {"Healthy"})] + [InlineData(KnownResourceState.Running, DiagnosticsHealthStatus.Unhealthy, new string?[] {null})] + [InlineData(KnownResourceState.Running, DiagnosticsHealthStatus.Degraded, new string?[] {"Healthy", "Degraded"})] + public void Resource_WithHealthReportAndState_ReturnsCorrectHealthStatus(KnownResourceState? state, DiagnosticsHealthStatus? expectedStatus, string?[]? healthStatusStrings) + { + var reports = healthStatusStrings?.Select((h, i) => new HealthReportViewModel(i.ToString(), h is null ? null : System.Enum.Parse(h), null, null)).ToImmutableArray() ?? []; + var actualStatus = ResourceViewModel.ComputeHealthStatus(reports, state); + Assert.Equal(expectedStatus, actualStatus); + } + [Fact] public void ToViewModel_EmptyEnvVarName_Success() { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index c6649acd341..38b4a3f58bf 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -10,7 +10,6 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -18,8 +17,6 @@ using Xunit; using Xunit.Abstractions; using DashboardService = Aspire.Hosting.Dashboard.DashboardService; -using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; -using ProtoHealthStatus = Aspire.ResourceService.Proto.V1.HealthStatus; using Resource = Aspire.Hosting.ApplicationModel.Resource; namespace Aspire.Hosting.Tests.Dashboard; @@ -148,80 +145,6 @@ await dashboardServiceData.WaitForResourceAsync(testResource.Name, r => await CancelTokenAndAwaitTask(cts, task).DefaultTimeout(); } - [Fact] - public async Task CreateResource_NoChild_WithHealthChecks_ResourceImmediatelyReturnsFakeHealthReports_ThenUpdates() - { - // Arrange - var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService); - using var dashboardServiceData = new DashboardServiceData(resourceNotificationService, resourceLoggerService, NullLogger.Instance, new DashboardCommandExecutor(new ServiceCollection().BuildServiceProvider())); - var dashboardService = new DashboardService(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), NullLogger.Instance); - - var testResource = new TestResource("test-resource"); - using var builder = TestDistributedApplicationBuilder.Create(); - builder.Services.AddHealthChecks() - .AddCheck("Check1", () => HealthCheckResult.Healthy()) - .AddCheck("Check2", () => HealthCheckResult.Healthy()); - - builder.AddResource(testResource) - .WithHealthCheck("Check1") - .WithHealthCheck("Check2"); - - var cts = new CancellationTokenSource(); - var context = TestServerCallContext.Create(cancellationToken: cts.Token); - var writer = new TestServerStreamWriter(context); - - // Act - var task = dashboardService.WatchResources( - new WatchResourcesRequest(), - writer, - context); - - // Assert - await writer.ReadNextAsync().DefaultTimeout(); - await resourceNotificationService.PublishUpdateAsync(testResource, s => - { - return s with { State = new ResourceStateSnapshot("Starting", null) }; - }).DefaultTimeout(); - - var resource = Assert.Single((await writer.ReadNextAsync().DefaultTimeout()).Changes.Value).Upsert; - Assert.False(resource.HasHealthStatus); - Assert.Collection(resource.HealthReports, - r => - { - Assert.Equal("Check1", r.Key); - Assert.False(r.HasStatus); - }, - r => - { - Assert.Equal("Check2", r.Key); - Assert.False(r.HasStatus); - }); - - await resourceNotificationService.PublishUpdateAsync(testResource, s => - { - // simulate only having received health check report from one of the checks - return s with { HealthReports = [new HealthReportSnapshot("Check1", HealthStatus.Healthy, null, null)] }; - }).DefaultTimeout(); - - var updateAfterCheck = await writer.ReadNextAsync().DefaultTimeout(); - var upsert = Assert.Single(updateAfterCheck.Changes.Value).Upsert; - - Assert.Collection(upsert.HealthReports, - r => - { - Assert.Equal("Check1", r.Key); - Assert.Equal(ProtoHealthStatus.Healthy, r.Status); - }, - r => - { - Assert.Equal("Check2", r.Key); - Assert.False(r.HasStatus); - }); - - await CancelTokenAndAwaitTask(cts, task).DefaultTimeout(); - } - private sealed class TestHostEnvironment : IHostEnvironment { public string ApplicationName { get; set; } = default!; diff --git a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs index 1564034fa7d..9f4fb0e48b3 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs @@ -199,7 +199,6 @@ private static GenericResourceSnapshot CreateResourceSnapshot(string name) Urls = [], Volumes = [], Environment = [], - HealthStatus = null, HealthReports = [], Commands = [] }; diff --git a/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs b/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs new file mode 100644 index 00000000000..b358968ca08 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Health/HealthStatusTests.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +namespace Aspire.Hosting.Tests.Health; + +public class HealthStatusTests +{ + private const string StartingState = "Starting"; + private const string RunningState = "Running"; + + [Theory] + [InlineData(StartingState, null, null)] + [InlineData(StartingState, null, new string[]{})] + [InlineData(StartingState, null, new string?[]{null})] + // we don't have a Running + HealthReports null case because that's not a valid state - by this point, we will have received the list of HealthReports + [InlineData(RunningState, HealthStatus.Healthy, new string[]{})] + [InlineData(RunningState, HealthStatus.Healthy, new string?[] {"Healthy"})] + [InlineData(RunningState, HealthStatus.Unhealthy, new string?[] {null})] + [InlineData(RunningState, HealthStatus.Degraded, new string?[] {"Healthy", "Degraded"})] + public void Resource_WithHealthReportAndState_ReturnsCorrectHealthStatus(string? state, HealthStatus? expectedStatus, string?[]? healthStatusStrings) + { + var reports = healthStatusStrings?.Select((h, i) => new HealthReportSnapshot(i.ToString(), h is null ? null : Enum.Parse(h), null, null)).ToImmutableArray() ?? []; + var actualStatus = CustomResourceSnapshot.ComputeHealthStatus(reports, state); + Assert.Equal(expectedStatus, actualStatus); + } +} diff --git a/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs b/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs index 7bc37e09e4a..507eef4cb3a 100644 --- a/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Health/ResourceHealthCheckServiceTests.cs @@ -36,8 +36,8 @@ await rns.PublishUpdateAsync(resource.Resource, s => s with State = new ResourceStateSnapshot(KnownResourceStates.Running, null) }); - var runningEvent = await rns.WaitForResourceAsync("resource", e => e.Snapshot.State?.Text == KnownResourceStates.Running); - Assert.Equal(HealthStatus.Healthy, runningEvent.Snapshot.HealthStatus); + var healthyEvent = await rns.WaitForResourceHealthyAsync("resource"); + Assert.Equal(HealthStatus.Healthy, healthyEvent.Snapshot.HealthStatus); await app.StopAsync(); } @@ -71,16 +71,14 @@ await rns.PublishUpdateAsync(resource.Resource, s => s with }); var runningEvent = await rns.WaitForResourceAsync("resource", e => e.Snapshot.State?.Text == KnownResourceStates.Running); - Assert.Null(runningEvent.Snapshot.HealthStatus); - var hasHealthReportsEvent = await rns.WaitForResourceAsync("resource", e => e.Snapshot.HealthReports.Length > 0); - Assert.Equal(HealthStatus.Healthy, hasHealthReportsEvent.Snapshot.HealthStatus); + Assert.Equal(HealthStatus.Unhealthy, runningEvent.Snapshot.HealthStatus); + await rns.WaitForResourceHealthyAsync("resource"); await app.StopAsync(); } [Fact] - [ActiveIssue("https://github.com/dotnet/aspire/issues/6363")] public async Task HealthCheckIntervalSlowsAfterSteadyHealthyState() { using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); diff --git a/tests/Shared/DashboardModel/ModelTestHelpers.cs b/tests/Shared/DashboardModel/ModelTestHelpers.cs index 6af2635a31d..2fb167dbd10 100644 --- a/tests/Shared/DashboardModel/ModelTestHelpers.cs +++ b/tests/Shared/DashboardModel/ModelTestHelpers.cs @@ -18,7 +18,8 @@ public static ResourceViewModel CreateResource( Dictionary? properties = null, string? resourceType = null, string? stateStyle = null, - HealthStatus? healthStatus = null) + HealthStatus? reportHealthStatus = null, + bool createNullHealthReport = false) { return new ResourceViewModel { @@ -36,8 +37,7 @@ public static ResourceViewModel CreateResource( State = state?.ToString(), KnownState = state, StateStyle = stateStyle, - HealthStatus = healthStatus, - HealthReports = [], + HealthReports = reportHealthStatus is null && !createNullHealthReport ? [] : [new HealthReportViewModel("healthcheck", reportHealthStatus, null, null)], Commands = [] }; }