Skip to content
Merged
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
83 changes: 46 additions & 37 deletions src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
Expand Down Expand Up @@ -68,15 +70,13 @@ public async Task<AppHostConnectionResult[]> ResolveAllConnectionsAsync(
/// <param name="projectFile">Optional project file. If specified, uses fast path to find matching socket.</param>
/// <param name="scanningMessage">Message to display while scanning for AppHosts.</param>
/// <param name="selectPrompt">Prompt to display when multiple AppHosts are found.</param>
/// <param name="noInScopeMessage">Message to display when no in-scope AppHosts are found but others exist.</param>
/// <param name="notFoundMessage">Message to display when no AppHosts are found.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved connection, or null with an error message.</returns>
public async Task<AppHostConnectionResult> ResolveConnectionAsync(
FileInfo? projectFile,
string scanningMessage,
string selectPrompt,
string noInScopeMessage,
string notFoundMessage,
CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -139,48 +139,21 @@ public async Task<AppHostConnectionResult> ResolveConnectionAsync(
}
else if (inScopeConnections.Count > 1)
{
// Multiple in-scope AppHosts running, prompt for selection
// Order by most recently started first
var choices = inScopeConnections
.OrderByDescending(c => c.AppHostInfo?.StartedAt ?? DateTimeOffset.MinValue)
.Select(c =>
{
var appHostPath = c.AppHostInfo?.AppHostPath ?? "Unknown";
var relativePath = Path.GetRelativePath(workingDirectory, appHostPath);
return (Display: relativePath, Connection: c);
})
.ToList();

var selectedDisplay = await interactionService.PromptForSelectionAsync(
selectedConnection = await PromptForAppHostSelectionAsync(
inScopeConnections,
SharedCommandStrings.MultipleInScopeAppHosts,
selectPrompt,
choices.Select(c => c.Display).ToArray(),
c => c.EscapeMarkup(),
path => Path.GetRelativePath(workingDirectory, path),
cancellationToken);

selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
}
else if (outOfScopeConnections.Count > 0)
{
// No in-scope AppHosts, but there are out-of-scope ones - let user pick
interactionService.DisplayMessage(KnownEmojis.Information, noInScopeMessage);

// Order by most recently started first
var choices = outOfScopeConnections
.OrderByDescending(c => c.AppHostInfo?.StartedAt ?? DateTimeOffset.MinValue)
.Select(c =>
{
var path = c.AppHostInfo?.AppHostPath ?? "Unknown";
return (Display: path, Connection: c);
})
.ToList();

var selectedDisplay = await interactionService.PromptForSelectionAsync(
selectedConnection = await PromptForAppHostSelectionAsync(
outOfScopeConnections,
SharedCommandStrings.NoInScopeAppHostsShowingAll,
selectPrompt,
choices.Select(c => c.Display).ToArray(),
c => c.EscapeMarkup(),
path => path,
cancellationToken);

selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
}

if (selectedConnection is null)
Expand All @@ -190,4 +163,40 @@ public async Task<AppHostConnectionResult> ResolveConnectionAsync(

return new AppHostConnectionResult { Connection = selectedConnection };
}

/// <summary>
/// Displays an informational message, prompts the user to select from available AppHost connections,
/// and displays the selected AppHost.
/// </summary>
private async Task<IAppHostAuxiliaryBackchannel?> PromptForAppHostSelectionAsync(
List<IAppHostAuxiliaryBackchannel> candidateConnections,
string contextMessage,
string selectPrompt,
Func<string, string> formatPath,
CancellationToken cancellationToken)
{
interactionService.DisplayMessage(KnownEmojis.Information, contextMessage);

// Order by most recently started first
var choices = candidateConnections
.OrderByDescending(c => c.AppHostInfo?.StartedAt ?? DateTimeOffset.MinValue)
.Select(c =>
{
var appHostPath = c.AppHostInfo?.AppHostPath ?? "Unknown";
return (Display: formatPath(appHostPath), Connection: c);
})
.ToList();

var selectedDisplay = await interactionService.PromptForSelectionAsync(
selectPrompt,
choices.Select(c => c.Display).ToArray(),
c => c.EscapeMarkup(),
cancellationToken);

var selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;

interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.UsingAppHost, selectedDisplay));

return selectedConnection;
}
}
125 changes: 87 additions & 38 deletions src/Aspire.Cli/Commands/DescribeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ internal sealed class DescribeCommand : BaseCommand

private readonly IInteractionService _interactionService;
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ResourceColorMap _resourceColorMap = new();

private static readonly Argument<string?> s_resourceArgument = new("resource")
{
Expand Down Expand Up @@ -120,7 +121,6 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
passedAppHostProjectFile,
SharedCommandStrings.ScanningForRunningAppHosts,
string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, DescribeCommandStrings.SelectAppHostAction),
SharedCommandStrings.NoInScopeAppHostsShowingAll,
SharedCommandStrings.AppHostNotRunning,
cancellationToken);

Expand Down Expand Up @@ -190,38 +190,66 @@ private async Task<int> ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connectio
var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;

// Maintain a dictionary of all resources seen so far for relationship resolution
var allResources = new Dictionary<string, ResourceSnapshot>(StringComparer.OrdinalIgnoreCase);
// 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 initialSnapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false);
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))
{
// Update the dictionary with the latest snapshot for this resource
// Update the dictionary with the latest state for this resource
allResources[snapshot.Name] = snapshot;

var currentSnapshots = allResources.Values.ToList();

// Filter by resource name if specified
if (resourceName is not null)
{
var resolved = ResourceSnapshotMapper.ResolveResources(resourceName, allResources.Values.ToList());
var resolved = ResourceSnapshotMapper.ResolveResources(resourceName, currentSnapshots);
if (!resolved.Any(r => string.Equals(r.Name, snapshot.Name, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
}

var resourceJson = ResourceSnapshotMapper.MapToResourceJson(snapshot, allResources.Values.ToList(), dashboardBaseUrl);

if (format == OutputFormat.Json)
{
var resourceJson = ResourceSnapshotMapper.MapToResourceJson(snapshot, currentSnapshots, dashboardBaseUrl);

// NDJSON output - compact, one object per line for streaming
var json = JsonSerializer.Serialize(resourceJson, ResourcesCommandJsonContext.Ndjson.ResourceJson);
// Structured output always goes to stdout.

// Skip if the JSON is identical to the last output for this resource
if (lastDisplayedContent.TryGetValue(snapshot.Name, out var lastValue) && lastValue is string lastJson && lastJson == json)
{
continue;
}

lastDisplayedContent[snapshot.Name] = json;
_interactionService.DisplayRawText(json, ConsoleOutput.Standard);
}
else
{
// Human-readable update
DisplayResourceUpdate(snapshot, allResources);
// Human-readable update - build display state and skip if unchanged
var displayState = BuildResourceDisplayState(snapshot, currentSnapshots);

if (lastDisplayedContent.TryGetValue(snapshot.Name, out var lastValue) && lastValue.Equals(displayState))
{
continue;
}

lastDisplayedContent[snapshot.Name] = displayState;
DisplayResourceUpdate(displayState);
}
}

Expand Down Expand Up @@ -251,49 +279,70 @@ private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots)
foreach (var (snapshot, displayName) in orderedItems)
{
var endpoints = snapshot.Urls.Length > 0
? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url))
? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url.EscapeMarkup()))
: "-";

var type = snapshot.ResourceType ?? "-";
var state = snapshot.State ?? "Unknown";
var health = snapshot.HealthStatus ?? "-";
var type = snapshot.ResourceType?.EscapeMarkup() ?? "-";
var stateText = ColorState(snapshot.State);
var healthText = ColorHealth(snapshot.HealthStatus?.EscapeMarkup() ?? "-");

// Color the state based on value
var stateText = state.ToUpperInvariant() switch
{
"RUNNING" => $"[green]{state}[/]",
"FINISHED" or "EXITED" => $"[grey]{state}[/]",
"FAILEDTOSTART" or "FAILED" => $"[red]{state}[/]",
"STARTING" or "WAITING" => $"[yellow]{state}[/]",
_ => state
};

// Color the health based on value
var healthText = health.ToUpperInvariant() switch
{
"HEALTHY" => $"[green]{health}[/]",
"UNHEALTHY" => $"[red]{health}[/]",
"DEGRADED" => $"[yellow]{health}[/]",
_ => health
};

table.AddRow(displayName, type, stateText, healthText, endpoints);
table.AddRow(ColorResourceName(displayName, displayName.EscapeMarkup()), type, stateText, healthText, endpoints);
}

_interactionService.DisplayRenderable(table);
}

private void DisplayResourceUpdate(ResourceSnapshot snapshot, IDictionary<string, ResourceSnapshot> allResources)
private static ResourceDisplayState BuildResourceDisplayState(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> allResources)
{
var displayName = ResourceSnapshotMapper.GetResourceName(snapshot, allResources);

var endpoints = snapshot.Urls.Length > 0
? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url))
: "";

var health = !string.IsNullOrEmpty(snapshot.HealthStatus) ? $" ({snapshot.HealthStatus})" : "";
var endpointsStr = !string.IsNullOrEmpty(endpoints) ? $" - {endpoints}" : "";
return new ResourceDisplayState(displayName, snapshot.State, snapshot.HealthStatus, endpoints);
}

private void DisplayResourceUpdate(ResourceDisplayState state)
{
var stateText = ColorState(state.State);
var healthText = !string.IsNullOrEmpty(state.HealthStatus) ? $" ({ColorHealth(state.HealthStatus.EscapeMarkup())})" : "";
var endpointsStr = !string.IsNullOrEmpty(state.Endpoints) ? $" - {state.Endpoints.EscapeMarkup()}" : "";

_interactionService.DisplayMarkupLine($"{ColorResourceName(state.DisplayName, $"[[{state.DisplayName.EscapeMarkup()}]]")} {stateText}{healthText}{endpointsStr}");
}

private string ColorResourceName(string name, string displayMarkup) =>
$"[{_resourceColorMap.GetColor(name)}]{displayMarkup}[/]";

private static string ColorState(string? state)
{
if (string.IsNullOrEmpty(state))
{
return "Unknown";
}

_interactionService.DisplayPlainText($"[{displayName}] {snapshot.State ?? "Unknown"}{health}{endpointsStr}");
var escaped = state.EscapeMarkup();
return state.ToUpperInvariant() switch
{
"RUNNING" => $"[green]{escaped}[/]",
"FINISHED" or "EXITED" => $"[grey]{escaped}[/]",
"FAILEDTOSTART" or "FAILED" => $"[red]{escaped}[/]",
"STARTING" or "WAITING" => $"[yellow]{escaped}[/]",
_ => escaped
};
}

private static string ColorHealth(string health) => health.ToUpperInvariant() switch
{
"HEALTHY" => $"[green]{health}[/]",
"UNHEALTHY" => $"[red]{health}[/]",
"DEGRADED" => $"[yellow]{health}[/]",
_ => health
};

/// <summary>
/// Represents the display state of a resource for deduplication during watch mode.
/// </summary>
private sealed record ResourceDisplayState(string DisplayName, string? State, string? HealthStatus, string Endpoints);
}
Loading
Loading