diff --git a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs index ef534b4fd24..ba23c03723a 100644 --- a/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostBackchannel.cs @@ -14,7 +14,7 @@ internal interface IAppHostBackchannel Task PingAsync(long timestamp, CancellationToken cancellationToken); Task RequestStopAsync(CancellationToken cancellationToken); Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync(CancellationToken cancellationToken); - IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync(CancellationToken cancellationToken); + IAsyncEnumerable GetResourceStatesAsync(CancellationToken cancellationToken); Task ConnectAsync(string socketPath, CancellationToken cancellationToken); IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync(CancellationToken cancellationToken); Task GetCapabilitiesAsync(CancellationToken cancellationToken); @@ -22,7 +22,7 @@ internal interface IAppHostBackchannel internal sealed class AppHostBackchannel(ILogger logger, CliRpcTarget target) : IAppHostBackchannel { - private const string BaselineCapability = "baseline.v1"; + private const string BaselineCapability = "baseline.v2"; private readonly ActivitySource _activitySource = new(nameof(AppHostBackchannel)); private readonly TaskCompletionSource _rpcTaskCompletionSource = new(); @@ -77,7 +77,7 @@ await rpc.InvokeWithCancellationAsync( return url; } - public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) + public async IAsyncEnumerable GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { using var activity = _activitySource.StartActivity(); @@ -85,7 +85,7 @@ await rpc.InvokeWithCancellationAsync( logger.LogDebug("Requesting resource states"); - var resourceStates = await rpc.InvokeWithCancellationAsync>( + var resourceStates = await rpc.InvokeWithCancellationAsync>( "GetResourceStatesAsync", Array.Empty(), cancellationToken); diff --git a/src/Aspire.Cli/Backchannel/RpcResourceState.cs b/src/Aspire.Cli/Backchannel/RpcResourceState.cs new file mode 100644 index 00000000000..bf4164f1afb --- /dev/null +++ b/src/Aspire.Cli/Backchannel/RpcResourceState.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Backchannel; + +/// +/// Represents the state of a resource reported via RPC. +/// +public class RpcResourceState +{ + /// + /// Gets the name of the resource. + /// + public required string Resource { get; init; } + + /// + /// Gets the type of the resource. + /// + public required string Type { get; init; } + + /// + /// Gets the state of the resource. + /// + public required string State { get; init; } + + /// + /// Gets the endpoints associated with the resource. + /// + public required string[] Endpoints { get; init; } + + /// + /// Gets the health status of the resource. + /// + public string? Health { get; init; } +} \ No newline at end of file diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index f9ab118cda3..1498db7e84e 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -174,11 +174,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell await _ansiConsole.Live(rows).StartAsync(async context => { - var knownResources = new SortedDictionary(); + var knownResources = new SortedDictionary(); table.AddColumn("Resource"); table.AddColumn("Type"); table.AddColumn("State"); + table.AddColumn("Health"); table.AddColumn("Endpoint(s)"); var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken); @@ -210,6 +211,15 @@ await _ansiConsole.Live(rows).StartAsync(async context => _ => new Text(knownResource.Value.State ?? "Unknown", new Style().Foreground(Color.Grey)) }; + var healthRenderable = knownResource.Value.Health switch + { + "Healthy" => new Text(knownResource.Value.Health, new Style().Foreground(Color.Green)), + "Degraded" => new Text(knownResource.Value.Health, new Style().Foreground(Color.Yellow)), + "Unhealthy" => new Text(knownResource.Value.Health, new Style().Foreground(Color.Red)), + null => new Text("Unknown", new Style().Foreground(Color.Grey)), + _ => new Text(knownResource.Value.Health, new Style().Foreground(Color.Grey)) + }; + IRenderable endpointsRenderable = new Text("None"); if (knownResource.Value.Endpoints?.Length > 0) { @@ -218,7 +228,7 @@ await _ansiConsole.Live(rows).StartAsync(async context => ); } - table.AddRow(nameRenderable, typeRenderable, stateRenderable, endpointsRenderable); + table.AddRow(nameRenderable, typeRenderable, stateRenderable, healthRenderable, endpointsRenderable); } context.Refresh(); diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 79ad2fced93..0aba9ee411d 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -52,7 +52,7 @@ DistributedApplicationOptions options } } - public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) + public async IAsyncEnumerable GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { var resourceEvents = resourceNotificationService.WatchAsync(cancellationToken); @@ -74,13 +74,18 @@ DistributedApplicationOptions options .Where(e => e.AllocatedEndpoint != null) .Select(e => e.AllocatedEndpoint!.UriString) .ToArray(); - // TODO: Decide on whether we want to define a type and share it between codebases for this. - yield return ( - resourceEvent.Resource.Name, - resourceEvent.Snapshot.ResourceType, - resourceEvent.Snapshot.State?.Text ?? "Unknown", - endpointUris - ); + + // Compute health status + var healthStatus = CustomResourceSnapshot.ComputeHealthStatus(resourceEvent.Snapshot.HealthReports, resourceEvent.Snapshot.State?.Text); + + yield return new RpcResourceState + { + Resource = resourceEvent.Resource.Name, + Type = resourceEvent.Snapshot.ResourceType, + State = resourceEvent.Snapshot.State?.Text ?? "Unknown", + Endpoints = endpointUris, + Health = healthStatus?.ToString() + }; } } @@ -169,7 +174,7 @@ public Task GetCapabilitiesAsync(CancellationToken cancellationToken) _ = cancellationToken; return Task.FromResult(new string[] { - "baseline.v1" + "baseline.v2" }); } #pragma warning restore CA1822 diff --git a/src/Aspire.Hosting/Backchannel/RpcResourceState.cs b/src/Aspire.Hosting/Backchannel/RpcResourceState.cs new file mode 100644 index 00000000000..1e6fd9c5d3c --- /dev/null +++ b/src/Aspire.Hosting/Backchannel/RpcResourceState.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Backchannel; + +/// +/// Represents the state of a resource reported via RPC. +/// +public class RpcResourceState +{ + /// + /// Gets the name of the resource. + /// + public required string Resource { get; init; } + + /// + /// Gets the type of the resource. + /// + public required string Type { get; init; } + + /// + /// Gets the state of the resource. + /// + public required string State { get; init; } + + /// + /// Gets the endpoints associated with the resource. + /// + public required string[] Endpoints { get; init; } + + /// + /// Gets the health status of the resource. + /// + public string? Health { get; init; } +} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs index fa9d06bce3d..e14b756e959 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs @@ -18,7 +18,7 @@ internal sealed class TestAppHostBackchannel : IAppHostBackchannel public Func>? GetDashboardUrlsAsyncCallback { get; set; } public TaskCompletionSource? GetResourceStatesAsyncCalled { get; set; } - public Func>? GetResourceStatesAsyncCallback { get; set; } + public Func>? GetResourceStatesAsyncCallback { get; set; } public TaskCompletionSource? ConnectAsyncCalled { get; set; } public Func? ConnectAsyncCallback { get; set; } @@ -58,7 +58,7 @@ public Task RequestStopAsync(CancellationToken cancellationToken) : Task.FromResult<(string, string?)>(("http://localhost:5000/login?t=abcd", "https://monalisa-hot-potato-vrpqrxxrx7x2rxx-5000.app.github.dev/login?t=abcd")); } - public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) + public async IAsyncEnumerable GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { GetResourceStatesAsyncCalled?.SetResult(); @@ -75,8 +75,22 @@ public Task RequestStopAsync(CancellationToken cancellationToken) using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync(cancellationToken)) { - yield return ("frontend", "Project", "Starting", new[] { "http://localhost:5000" }); - yield return ("backend", "Project", "Running", new[] { "http://localhost:5001" }); + yield return new RpcResourceState + { + Resource = "frontend", + Type = "Project", + State = "Starting", + Endpoints = new[] { "http://localhost:5000" }, + Health = "Healthy" + }; + yield return new RpcResourceState + { + Resource = "backend", + Type = "Project", + State = "Running", + Endpoints = new[] { "http://localhost:5001" }, + Health = "Healthy" + }; } } } @@ -124,7 +138,7 @@ public async Task GetCapabilitiesAsync(CancellationToken cancellationT } else { - return ["baseline.v1"]; + return ["baseline.v2"]; } } } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs index 36921cbefb3..daf241bfb29 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AppHostBackchannelTests.cs @@ -119,7 +119,7 @@ public async Task CanStreamResourceStates() using var stream = new NetworkStream(socket, true); using var rpc = JsonRpc.Attach(stream); - var resourceEvents = await rpc.InvokeAsync>( + var resourceEvents = await rpc.InvokeAsync>( "GetResourceStatesAsync", Array.Empty() ).WaitAsync(TimeSpan.FromSeconds(60));