Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
150ec05
Remove HealthStatus defaulting to healthy when there is no value from…
Oct 9, 2024
c26e8dd
Show unhealthy only if health status is not null
Oct 14, 2024
57f11be
Add null assert to status in ResourceNotificationTests
Oct 14, 2024
c66b778
Add test for StateColumnDisplay static functions
Oct 14, 2024
7fb3fa0
Merge branch 'main' into dev/adamint/remove-default-healthy-status
Oct 14, 2024
a2df9ed
Update with new expectations
Oct 14, 2024
049dcee
Create initial health reports if we have not received from server
Oct 15, 2024
951e1f9
update public api
Oct 15, 2024
19bb010
Plumb nullable health status / reports through, create fake snapshots…
Oct 15, 2024
613784c
Make resource healthy if it is running with no health checks
Oct 15, 2024
2e10709
Update comment
Oct 15, 2024
f138e46
Clean up
Oct 15, 2024
c726d5a
Add initial test, move health status update logic elsewhere in notifi…
Oct 15, 2024
54c32fd
Add two more tests
Oct 16, 2024
78178d8
Fix flaky test
Oct 16, 2024
65cd571
Simplify test
Oct 16, 2024
caf163e
Merge branch 'refs/heads/main' into dev/adamint/remove-default-health…
Oct 16, 2024
a28f89d
Apply PR suggestions
Oct 17, 2024
254b88d
Merge branch 'main' into dev/adamint/remove-default-healthy-status
Oct 17, 2024
6535aae
Add RuntimeUnhealthy state to known states, as it is affected in Stat…
Oct 17, 2024
c37db7d
Refactor state column display and its tests
JamesNK Oct 17, 2024
749c0b6
Runtime unhealthy improvements
JamesNK Oct 17, 2024
e168dc9
Merge
JamesNK Oct 17, 2024
ac14849
Fix merge
JamesNK Oct 17, 2024
0297102
Fix state not changing
JamesNK Oct 17, 2024
ead2b13
check at beginning of update health status if health status is not al…
Oct 17, 2024
3eacb74
Set state to running (unhealthy) on empty health status
Oct 17, 2024
3f23964
Update test expectations
Oct 17, 2024
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
@@ -1,7 +1,7 @@
@using Aspire.Dashboard.Model
@using Aspire.Dashboard.Resources

@{
var vm = GetStateViewModel();
var vm = GetStateViewModel(Resource, Loc[Columns.UnknownStateLabel]);
}

<div class="state-column-cell">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,40 @@ public partial class StateColumnDisplay
[Inject]
public required IStringLocalizer<Columns> Loc { get; init; }

internal static string? GetResourceStateTooltip(ResourceViewModel resource, IStringLocalizer<Columns> loc)
{
return GetResourceStateTooltip(
resource,
loc[Columns.StateColumnResourceExitedUnexpectedly].Value,
loc[Columns.StateColumnResourceExited].Value,
loc[nameof(Columns.RunningAndUnhealthyResourceStateToolTip)]);
}

/// <summary>
/// Gets the tooltip for a cell in the state column of the resource grid.
/// </summary>
/// <remarks>
/// This is a static method so it can be called at the level of the parent column.
/// </remarks>
public static string? GetResourceStateTooltip(ResourceViewModel resource, IStringLocalizer<Columns> Loc)
internal static string? GetResourceStateTooltip(ResourceViewModel resource, string exitedUnexpectedlyTooltip, string exitedTooltip, string runningAndUnhealthyTooltip)
{
if (resource.IsStopped())
{
if (resource.TryGetExitCode(out var exitCode) && exitCode is not 0)
{
// Process completed unexpectedly, hence the non-zero code. This is almost certainly an error, so warn users.
return string.Format(CultureInfo.CurrentCulture, Loc[Columns.StateColumnResourceExitedUnexpectedly], resource.ResourceType, exitCode);
return string.Format(CultureInfo.CurrentCulture, exitedUnexpectedlyTooltip, resource.ResourceType, exitCode);
}
else
{
// Process completed, which may not have been unexpected.
return string.Format(CultureInfo.CurrentCulture, Loc[Columns.StateColumnResourceExited], resource.ResourceType);
return string.Format(CultureInfo.CurrentCulture, exitedTooltip, resource.ResourceType);
}
}
else if (resource.KnownState is KnownResourceState.Running && resource.HealthStatus is not HealthStatus.Healthy)
else if (resource is { KnownState: KnownResourceState.Running, HealthStatus: not HealthStatus.Healthy and not null })
{
// Resource is running but not healthy (initializing).
return Loc[nameof(Columns.RunningAndUnhealthyResourceStateToolTip)];
return runningAndUnhealthyTooltip;
}

return null;
Expand All @@ -58,22 +67,22 @@ public partial class StateColumnDisplay
/// <summary>
/// Gets data needed to populate the content of the state column.
/// </summary>
private ResourceStateViewModel GetStateViewModel()
internal static ResourceStateViewModel GetStateViewModel(ResourceViewModel resource, string unknownStateLabel)
{
// Browse the icon library at: https://aka.ms/fluentui-system-icons

Icon icon;
Color color;

if (Resource.IsStopped())
if (resource.IsStopped())
{
if (Resource.TryGetExitCode(out var exitCode) && exitCode is not 0)
if (resource.TryGetExitCode(out var exitCode) && exitCode is not 0)
{
// Process completed unexpectedly, hence the non-zero code. This is almost certainly an error, so warn users.
icon = new Icons.Filled.Size16.ErrorCircle();
color = Color.Error;
}
else if (Resource.IsFinishedState())
else if (resource.IsFinishedState())
{
// Process completed successfully.
icon = new Icons.Filled.Size16.CheckmarkUnderlineCircle();
Expand All @@ -86,24 +95,24 @@ private ResourceStateViewModel GetStateViewModel()
color = Color.Warning;
}
}
else if (Resource.IsUnusableTransitoryState() || Resource.IsUnknownState())
else if (resource.IsUnusableTransitoryState() || resource.IsUnknownState())
{
icon = new Icons.Filled.Size16.CircleHint(); // A dashed, hollow circle.
color = Color.Info;
}
else if (Resource.HasNoState())
else if (resource.HasNoState())
{
icon = new Icons.Filled.Size16.Circle();
color = Color.Neutral;
}
else if (Resource.HealthStatus is not HealthStatus.Healthy)
else if (resource.HealthStatus is not HealthStatus.Healthy and not null)
{
icon = new Icons.Filled.Size16.CheckmarkCircleWarning();
color = Color.Neutral;
}
else if (!string.IsNullOrEmpty(Resource.StateStyle))
else if (!string.IsNullOrEmpty(resource.StateStyle))
{
(icon, color) = Resource.StateStyle switch
(icon, color) = resource.StateStyle switch
{
"warning" => ((Icon)new Icons.Filled.Size16.Warning(), Color.Warning),
"error" => (new Icons.Filled.Size16.ErrorCircle(), Color.Error),
Expand All @@ -118,15 +127,15 @@ private ResourceStateViewModel GetStateViewModel()
color = Color.Success;
}

var text = Resource switch
var text = resource switch
{
{ State: null or "" } => Loc[Columns.UnknownStateLabel],
{ KnownState: KnownResourceState.Running, HealthStatus: not HealthStatus.Healthy } => $"{Resource.State.Humanize()} ({(Resource.HealthStatus ?? HealthStatus.Unhealthy).Humanize()})",
_ => Resource.State.Humanize()
{ State: null or "" } => unknownStateLabel,
{ KnownState: KnownResourceState.Running, HealthStatus: not HealthStatus.Healthy and not null } => $"{resource.State.Humanize()} ({(resource.HealthStatus ?? HealthStatus.Unhealthy).Humanize()})",
_ => resource.State.Humanize()
};

return new ResourceStateViewModel(text, icon, color);
}

private record class ResourceStateViewModel(string Text, Icon Icon, Color Color);
internal record class ResourceStateViewModel(string Text, Icon Icon, Color Color);
}
9 changes: 3 additions & 6 deletions src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,11 @@ public sealed record CustomResourceSnapshot
/// </summary>
/// <remarks>
/// <para>
/// This value is derived from <see cref="HealthReports"/>. However, if a resource
/// is known to have a health check and no reports exist, then this value is <see langword="null"/>.
/// </para>
/// <para>
/// Defaults to <see cref="HealthStatus.Healthy"/>.
/// This value is derived from <see cref="HealthReports"/>. 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 <see langword="null"/>.
/// </para>
/// </remarks>
public HealthStatus? HealthStatus { get; init; } = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy;
public HealthStatus? HealthStatus { get; init; }

/// <summary>
/// The health reports for this resource.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,9 +493,7 @@ private static CustomResourceSnapshot GetCurrentSnapshot(IResource resource, Res
{
ResourceType = resource.GetType().Name,
Properties = [],
HealthStatus = resource.TryGetAnnotationsIncludingAncestorsOfType<HealthCheckAnnotation>(out _)
? null // Indicates that the resource has health check annotations but the health status is unknown.
: HealthStatus.Healthy
HealthStatus = null
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// 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.Frozen;
using Aspire.Dashboard.Components.ResourcesGridColumns;
using Aspire.Dashboard.Model;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
using Enum = System.Enum;

namespace Aspire.Dashboard.Components.Tests.Controls;

public class StateColumnDisplayTests
{
private const string ResourceType = "TestResourceType";
private const string ExitedUnexpectedlyTooltip = "Exited Unexpectedly {0} {1}";
private const string ExitedTooltip = "Exited {0}";
private const string RunningAndUnhealthyTooltip = "Running and Unhealthy";
private const string UnknownStateLabel = "Unknown";

[Theory]
// Resource is no longer running
[InlineData(
/* state */ KnownResourceState.Exited, null, null,null,
/* expected output */ "Exited TestResourceType", "Warning", Color.Warning, "Exited")]
[InlineData(
/* state */ KnownResourceState.Exited, 3, null, null,
/* expected output */ "Exited Unexpectedly TestResourceType 3", "ErrorCircle", Color.Error, "Exited")]
[InlineData(
/* state */ KnownResourceState.Finished, 0, null, null,
/* expected output */ "Exited TestResourceType", "CheckmarkUnderlineCircle", Color.Success, "Finished")]
[InlineData(
/* state */ KnownResourceState.Unknown, null, null, null,
/* expected output */ null, "CircleHint", Color.Info, "Unknown")]
// Health checks
[InlineData(
/* state */ KnownResourceState.Running, null, "Healthy", null,
/* expected output */ null, "CheckmarkCircle", Color.Success, "Running")]
[InlineData(
/* state */ KnownResourceState.Running, null, null, null,
/* expected output */ null, "CheckmarkCircle", Color.Success, "Running")]
[InlineData(
/* state */ KnownResourceState.Running, null, "Unhealthy", null,
/* expected output */ RunningAndUnhealthyTooltip, "CheckmarkCircleWarning", Color.Neutral, "Running (Unhealthy)")]
[InlineData(
/* state */ KnownResourceState.Running, null, null, "warning",
/* expected output */ null, "Warning", Color.Warning, "Running")]
[InlineData(
/* state */ KnownResourceState.Running, null, null, "NOT_A_VALID_STATE_STYLE",
/* expected output */ null, "Circle", Color.Neutral, "Running")]
public void ResourceViewModel_ReturnsCorrectIconAndTooltip(
KnownResourceState state,
int? exitCode,
string? healthStatusString,
string? stateStyle,
string? expectedTooltip,
string expectedIconName,
Color expectedColor,
string expectedText)
{
// Arrange
HealthStatus? healthStatus = healthStatusString is null ? null : Enum.Parse<HealthStatus>(healthStatusString);
var propertiesDictionary = new Dictionary<string, ResourcePropertyViewModel>();
if (exitCode is not null)
{
propertiesDictionary.TryAdd(KnownProperties.Resource.ExitCode, new ResourcePropertyViewModel(KnownProperties.Resource.ExitCode, Value.ForNumber((double)exitCode), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory())));
}

var resource = new ResourceViewModel
{
// these are the properties that affect this column
State = state.ToString(),
KnownState = state,
HealthStatus = healthStatus,
StateStyle = stateStyle,
Properties = propertiesDictionary.ToFrozenDictionary(),

// these properties don't matter
Name = string.Empty,
ResourceType = ResourceType,
DisplayName = string.Empty,
Uid = string.Empty,
CreationTimeStamp = null,
StartTimeStamp = null,
StopTimeStamp = null,
Environment = default,
Urls = default,
Volumes = default,
Commands = default,
HealthReports = default
};

if (exitCode is not null)
{
resource.Properties.TryAdd(KnownProperties.Resource.ExitCode, new ResourcePropertyViewModel(KnownProperties.Resource.ExitCode, Value.ForNumber((double)exitCode), false, null, 0, new BrowserTimeProvider(new NullLoggerFactory())));
}

// Act
var tooltip = StateColumnDisplay.GetResourceStateTooltip(resource, ExitedUnexpectedlyTooltip, ExitedTooltip, RunningAndUnhealthyTooltip);
var vm = StateColumnDisplay.GetStateViewModel(resource, UnknownStateLabel);

// Assert
Assert.Equal(expectedTooltip, tooltip);

Assert.Equal(expectedIconName, vm.Icon.Name);
Assert.Equal(expectedColor, vm.Color);
Assert.Equal(expectedText, vm.Text);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using Aspire.Dashboard.Model;
using Aspire.Hosting.Dashboard;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Xunit;

namespace Aspire.Hosting.Tests.Dashboard;
Expand Down Expand Up @@ -200,7 +199,7 @@ private static GenericResourceSnapshot CreateResourceSnapshot(string name)
Urls = [],
Volumes = [],
Environment = [],
HealthStatus = HealthStatus.Healthy,
HealthStatus = null,
HealthReports = [],
Commands = []
};
Expand Down
2 changes: 2 additions & 0 deletions tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,15 @@ async Task<List<ResourceEvent>> GetValuesAsync(CancellationToken cancellationTok
Assert.Equal("myResource", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "A").Value);
Assert.Null(c.Snapshot.HealthStatus);
},
c =>
{
Assert.Equal(resource, c.Resource);
Assert.Equal("myResource", c.ResourceId);
Assert.Equal("CustomResource", c.Snapshot.ResourceType);
Assert.Equal("value", c.Snapshot.Properties.Single(p => p.Name == "B").Value);
Assert.Null(c.Snapshot.HealthStatus);
});
}

Expand Down
Loading