Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 15 additions & 7 deletions src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ await rpc.InvokeWithCancellationAsync(
}

/// <inheritdoc />
public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default)
public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default)
{
var rpc = EnsureConnected();

Expand All @@ -269,10 +269,13 @@ public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(Cancellation
[],
cancellationToken).ConfigureAwait(false) ?? [];

// Sort resources by name for consistent ordering.
snapshots.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
if (!includeHidden)
{
snapshots = snapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)).ToList();
}

return snapshots;
// Sort resources by name for consistent ordering.
return snapshots.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
catch (RemoteMethodNotFoundException ex)
{
Expand All @@ -282,7 +285,7 @@ public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(Cancellation
}

/// <inheritdoc />
public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var rpc = EnsureConnected();

Expand All @@ -309,6 +312,11 @@ public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync([Enu

await foreach (var snapshot in snapshots.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (!includeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot))
{
continue;
}

yield return snapshot;
}
}
Expand Down Expand Up @@ -463,7 +471,7 @@ public async Task<GetResourcesResponse> GetResourcesV2Async(GetResourcesRequest?
if (!SupportsV2)
{
// Fall back to v1
var snapshots = await GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false);
var snapshots = await GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);

// Apply filter if specified
if (!string.IsNullOrEmpty(request?.Filter))
Expand Down Expand Up @@ -503,7 +511,7 @@ public async IAsyncEnumerable<ResourceSnapshot> WatchResourcesV2Async(
{
// Fall back to v1
var filter = request?.Filter;
await foreach (var snapshot in WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false))
await foreach (var snapshot in WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false))
{
if (!string.IsNullOrEmpty(filter) && !snapshot.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
{
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,18 @@ internal interface IAppHostAuxiliaryBackchannel : IDisposable
/// <summary>
/// Gets the current resource snapshots from the AppHost.
/// </summary>
/// <param name="includeHidden">When <see langword="true"/>, includes resources with hidden state.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of resource snapshots representing current state.</returns>
Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default);
Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default);

/// <summary>
/// Watches for resource snapshot changes and streams them from the AppHost.
/// </summary>
/// <param name="includeHidden">When <see langword="true"/>, includes resources with hidden state.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of resource snapshots as they change.</returns>
IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync(CancellationToken cancellationToken = default);
IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default);

/// <summary>
/// Gets resource log lines from the AppHost.
Expand Down
38 changes: 38 additions & 0 deletions src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,42 @@ public static string GetResourceName(ResourceSnapshot resource, IEnumerable<Reso

return resource.DisplayName ?? resource.Name;
}

/// <summary>
/// Determines whether a resource snapshot represents a hidden resource.
/// A resource is hidden if its <see cref="ResourceSnapshot.IsHidden"/> flag is set
/// or its <see cref="ResourceSnapshot.State"/> is "Hidden".
/// </summary>
internal static bool IsHiddenResource(ResourceSnapshot snapshot)
{
return snapshot.IsHidden || string.Equals(snapshot.State, "Hidden", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Filters a list of all resource snapshots based on hidden-resource visibility,
/// returning the visible snapshot list and a set of hidden resource names for log filtering.
/// When <paramref name="includeHidden"/> is <see langword="true"/> or <paramref name="resourceName"/>
/// is specified, all resources are included and the hidden set is empty.
/// </summary>
/// <param name="allSnapshots">All resource snapshots (including hidden).</param>
/// <param name="includeHidden">Whether the user explicitly requested hidden resources.</param>
/// <param name="resourceName">The specific resource name requested, or <see langword="null"/> for all.</param>
/// <returns>The effective include-hidden flag, the filtered snapshot list, and the set of hidden resource names.</returns>
internal static (bool EffectiveIncludeHidden, List<ResourceSnapshot> Snapshots, HashSet<string> HiddenResourceNames) FilterHiddenResources(
IReadOnlyList<ResourceSnapshot> allSnapshots,
bool includeHidden,
string? resourceName)
{
var effectiveIncludeHidden = includeHidden || resourceName is not null;

var hiddenResourceNames = effectiveIncludeHidden
? new HashSet<string>(StringComparers.ResourceName)
: new HashSet<string>(allSnapshots.Where(IsHiddenResource).Select(s => s.Name), StringComparers.ResourceName);

var snapshots = effectiveIncludeHidden
? allSnapshots.ToList()
: allSnapshots.Where(s => !IsHiddenResource(s)).ToList();

return (effectiveIncludeHidden, snapshots, hiddenResourceNames);
}
}
132 changes: 132 additions & 0 deletions src/Aspire.Cli/Backchannel/ResourceSnapshotWatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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.Concurrent;

namespace Aspire.Cli.Backchannel;

/// <summary>
/// Watches for resource snapshot changes from an AppHost backchannel connection
/// and maintains an up-to-date collection of resources.
/// </summary>
internal sealed class ResourceSnapshotWatcher : IDisposable
{
private readonly IAppHostAuxiliaryBackchannel _connection;
private readonly ConcurrentDictionary<string, ResourceSnapshot> _resources = new(StringComparers.ResourceName);
private readonly CancellationTokenSource _cts = new();
private readonly TaskCompletionSource _initialLoadTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Task _watchTask;
private volatile Exception? _watchException;

public ResourceSnapshotWatcher(IAppHostAuxiliaryBackchannel connection, bool includeHidden = false)
{
_connection = connection;
IncludeHidden = includeHidden;
_watchTask = WatchAsync(_cts.Token);
}

/// <summary>
/// Gets a value indicating whether hidden resources are included by default in <see cref="GetResources()"/>.
/// </summary>
public bool IncludeHidden { get; }

/// <summary>
/// Waits until the initial resource snapshot load is complete.
/// </summary>
public Task WaitForInitialLoadAsync(CancellationToken cancellationToken = default)
{
return _initialLoadTcs.Task.WaitAsync(cancellationToken);
}

private async Task WatchAsync(CancellationToken cancellationToken)
{
try
{
var snapshots = await _connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);

foreach (var snapshot in snapshots)
{
_resources[snapshot.Name] = snapshot;
}

_initialLoadTcs.TrySetResult();

await foreach (var snapshot in _connection.WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false))
{
_resources[snapshot.Name] = snapshot;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_initialLoadTcs.TrySetCanceled(cancellationToken);
}
catch (Exception ex)
{
if (!_initialLoadTcs.TrySetException(ex))
{
// Initial load already completed; store for callers to detect.
_watchException = ex;
}
}
}

/// <summary>
/// Gets the exception that terminated the watch loop after the initial load, or <see langword="null"/> if the watch is still running.
/// </summary>
public Exception? WatchException => _watchException;

private void EnsureInitialLoadComplete()
{
if (!_initialLoadTcs.Task.IsCompletedSuccessfully)
{
throw new InvalidOperationException("Initial resource snapshot load has not completed. Call WaitForInitialLoadAsync first.");
}
}

/// <summary>
/// Gets a resource snapshot by name, or <see langword="null"/> if not found.
/// </summary>
public ResourceSnapshot? GetResource(string name)
{
EnsureInitialLoadComplete();
return _resources.GetValueOrDefault(name);
}

/// <summary>
/// Gets all current resource snapshots, using <see cref="IncludeHidden"/> to determine visibility.
/// </summary>
/// <returns>Resource snapshots, ordered by name.</returns>
public IEnumerable<ResourceSnapshot> GetResources()
{
return GetResources(IncludeHidden);
}

/// <summary>
/// Gets all current resource snapshots, including hidden resources.
/// </summary>
/// <returns>All resource snapshots, ordered by name.</returns>
public IEnumerable<ResourceSnapshot> GetAllResources()
{
return GetResources(includeHidden: true);
}

private IEnumerable<ResourceSnapshot> GetResources(bool includeHidden)
{
EnsureInitialLoadComplete();

var snapshots = _resources.Values.AsEnumerable();

if (!includeHidden)
{
snapshots = snapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s));
}

return snapshots.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase);
}

public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
50 changes: 26 additions & 24 deletions src/Aspire.Cli/Commands/DescribeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ internal sealed class DescribeCommand : BaseCommand
{
Description = DescribeCommandStrings.JsonOptionDescription
};
private static readonly Option<bool> s_includeHiddenOption = new("--include-hidden")
{
Description = DescribeCommandStrings.IncludeHiddenOptionDescription
};

public DescribeCommand(
IInteractionService interactionService,
Expand All @@ -110,6 +114,7 @@ public DescribeCommand(
Options.Add(s_appHostOption);
Options.Add(s_followOption);
Options.Add(s_formatOption);
Options.Add(s_includeHiddenOption);
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
Expand All @@ -120,6 +125,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
var follow = parseResult.GetValue(s_followOption);
var format = parseResult.GetValue(s_formatOption);
var includeHidden = parseResult.GetValue(s_includeHiddenOption);

var result = await _connectionResolver.ResolveConnectionAsync(
passedAppHostProjectFile,
Expand All @@ -137,27 +143,28 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

var connection = result.Connection!;

// Get dashboard URL and resource snapshots in parallel before
// dispatching to the snapshot or watch path.
// Get dashboard URL while the watcher loads initial snapshots.
// When a specific resource is requested, always include hidden resources
// so the user can describe any resource by name.
var effectiveIncludeHidden = includeHidden || resourceName is not null;
var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken);

await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);
using var resourceWatcher = new ResourceSnapshotWatcher(connection, effectiveIncludeHidden);
await resourceWatcher.WaitForInitialLoadAsync(cancellationToken).ConfigureAwait(false);

var dashboardBaseUrl = TelemetryCommandHelpers.ExtractDashboardBaseUrl((await dashboardUrlsTask.ConfigureAwait(false))?.BaseUrlWithLoginToken);
var snapshots = await snapshotsTask.ConfigureAwait(false);

// Pre-resolve colors for all resource names so that assignment is
// deterministic regardless of which resources are displayed.
_resourceColorMap.ResolveAll(snapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, snapshots)));
var allSnapshots = resourceWatcher.GetAllResources();
_resourceColorMap.ResolveAll(allSnapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, allSnapshots)));

if (follow)
{
return await ExecuteWatchAsync(connection, snapshots, dashboardBaseUrl, resourceName, format, cancellationToken);
return await ExecuteWatchAsync(connection, resourceWatcher, dashboardBaseUrl, resourceName, format, cancellationToken);
}
else
{
return ExecuteSnapshot(snapshots, dashboardBaseUrl, resourceName, format);
return ExecuteSnapshot(resourceWatcher.GetResources().ToList(), dashboardBaseUrl, resourceName, format);
}
}

Expand Down Expand Up @@ -193,28 +200,23 @@ private int ExecuteSnapshot(IReadOnlyList<ResourceSnapshot> snapshots, string? d
return ExitCodeConstants.Success;
}

private async Task<int> ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, IReadOnlyList<ResourceSnapshot> initialSnapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
private async Task<int> ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, ResourceSnapshotWatcher resourceWatcher, string? dashboardBaseUrl, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
{
// Maintain a dictionary of the current state per resource for relationship resolution
// and display name deduplication. Keyed by snapshot.Name so each resource has exactly
// one entry representing its latest state.
var allResources = new Dictionary<string, ResourceSnapshot>(StringComparers.ResourceName);
foreach (var snapshot in initialSnapshots)
{
allResources[snapshot.Name] = snapshot;
}

// Cache the last displayed content per resource to avoid duplicate output.
// Values are either a string (JSON mode) or a ResourceDisplayState (non-JSON mode).
var lastDisplayedContent = new Dictionary<string, object>(StringComparers.ResourceName);

// Stream resource snapshots
await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false))
// Stream resource snapshots. The watcher keeps its dictionary up to date in the
// background, so we use it for relationship resolution and display name deduplication.
await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false))
{
// Update the dictionary with the latest state for this resource
allResources[snapshot.Name] = snapshot;
// Skip hidden resources when not included
if (!resourceWatcher.IncludeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot))
{
continue;
}

var currentSnapshots = allResources.Values.ToList();
var currentSnapshots = resourceWatcher.GetAllResources().ToList();

// Filter by resource name if specified
if (resourceName is not null)
Expand Down
Loading
Loading