Skip to content
8 changes: 4 additions & 4 deletions src/Aspire.Cli/Backchannel/AppHostBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ internal interface IAppHostBackchannel
Task<long> 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<RpcResourceState> GetResourceStatesAsync(CancellationToken cancellationToken);
Task ConnectAsync(string socketPath, CancellationToken cancellationToken);
IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync(CancellationToken cancellationToken);
Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken);
}

internal sealed class AppHostBackchannel(ILogger<AppHostBackchannel> 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<JsonRpc> _rpcTaskCompletionSource = new();
Expand Down Expand Up @@ -77,15 +77,15 @@ await rpc.InvokeWithCancellationAsync(
return url;
}

public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
public async IAsyncEnumerable<RpcResourceState> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
{
using var activity = _activitySource.StartActivity();

var rpc = await _rpcTaskCompletionSource.Task;

logger.LogDebug("Requesting resource states");

var resourceStates = await rpc.InvokeWithCancellationAsync<IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)>>(
var resourceStates = await rpc.InvokeWithCancellationAsync<IAsyncEnumerable<RpcResourceState>>(
"GetResourceStatesAsync",
Array.Empty<object>(),
cancellationToken);
Expand Down
35 changes: 35 additions & 0 deletions src/Aspire.Cli/Backchannel/RpcResourceState.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents the state of a resource reported via RPC.
/// </summary>
public class RpcResourceState
{
/// <summary>
/// Gets the name of the resource.
/// </summary>
public required string Resource { get; init; }

/// <summary>
/// Gets the type of the resource.
/// </summary>
public required string Type { get; init; }

/// <summary>
/// Gets the state of the resource.
/// </summary>
public required string State { get; init; }

/// <summary>
/// Gets the endpoints associated with the resource.
/// </summary>
public required string[] Endpoints { get; init; }

/// <summary>
/// Gets the health status of the resource.
/// </summary>
public string? Health { get; init; }
}
14 changes: 12 additions & 2 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,12 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

await _ansiConsole.Live(rows).StartAsync(async context =>
{
var knownResources = new SortedDictionary<string, (string Resource, string Type, string State, string[] Endpoints)>();
var knownResources = new SortedDictionary<string, RpcResourceState>();

table.AddColumn("Resource");
table.AddColumn("Type");
table.AddColumn("State");
table.AddColumn("Health");
table.AddColumn("Endpoint(s)");

var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken);
Expand Down Expand Up @@ -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)
{
Expand All @@ -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();
Expand Down
23 changes: 14 additions & 9 deletions src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ DistributedApplicationOptions options
}
}

public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
public async IAsyncEnumerable<RpcResourceState> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
{
var resourceEvents = resourceNotificationService.WatchAsync(cancellationToken);

Expand All @@ -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()
};
}
}

Expand Down Expand Up @@ -169,7 +174,7 @@ public Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken)

_ = cancellationToken;
return Task.FromResult(new string[] {
"baseline.v1"
"baseline.v2"
});
}
#pragma warning restore CA1822
Expand Down
35 changes: 35 additions & 0 deletions src/Aspire.Hosting/Backchannel/RpcResourceState.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents the state of a resource reported via RPC.
/// </summary>
public class RpcResourceState
{
/// <summary>
/// Gets the name of the resource.
/// </summary>
public required string Resource { get; init; }

/// <summary>
/// Gets the type of the resource.
/// </summary>
public required string Type { get; init; }

/// <summary>
/// Gets the state of the resource.
/// </summary>
public required string State { get; init; }

/// <summary>
/// Gets the endpoints associated with the resource.
/// </summary>
public required string[] Endpoints { get; init; }

/// <summary>
/// Gets the health status of the resource.
/// </summary>
public string? Health { get; init; }
}
24 changes: 19 additions & 5 deletions tests/Aspire.Cli.Tests/TestServices/TestAppHostBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ internal sealed class TestAppHostBackchannel : IAppHostBackchannel
public Func<CancellationToken, Task<(string, string?)>>? GetDashboardUrlsAsyncCallback { get; set; }

public TaskCompletionSource? GetResourceStatesAsyncCalled { get; set; }
public Func<CancellationToken, IAsyncEnumerable<(string, string, string, string[])>>? GetResourceStatesAsyncCallback { get; set; }
public Func<CancellationToken, IAsyncEnumerable<RpcResourceState>>? GetResourceStatesAsyncCallback { get; set; }

public TaskCompletionSource? ConnectAsyncCalled { get; set; }
public Func<string, CancellationToken, Task>? ConnectAsyncCallback { get; set; }
Expand Down Expand Up @@ -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<RpcResourceState> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
{
GetResourceStatesAsyncCalled?.SetResult();

Expand All @@ -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"
};
}
}
}
Expand Down Expand Up @@ -124,7 +138,7 @@ public async Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationT
}
else
{
return ["baseline.v1"];
return ["baseline.v2"];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)>>(
var resourceEvents = await rpc.InvokeAsync<IAsyncEnumerable<RpcResourceState>>(
"GetResourceStatesAsync",
Array.Empty<object>()
).WaitAsync(TimeSpan.FromSeconds(60));
Expand Down