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
1 change: 1 addition & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
<Compile Include="$(SharedDir)KnownFormats.cs" Link="Utils\KnownFormats.cs" />
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
<Compile Include="$(SharedDir)CircularBuffer.cs" Link="Utils\CircularBuffer.cs" />
<Compile Include="$(SharedDir)ColorGenerator.cs" Link="Utils\ColorGenerator.cs" />
<Compile Include="$(SharedDir)StringComparers.cs" Link="Utils\StringComparers.cs" />
<Compile Include="$(SharedDir)LocaleHelpers.cs" Link="Utils\LocaleHelpers.cs" />
<Compile Include="$(SharedDir)DurationFormatter.cs" Link="Utils\DurationFormatter.cs" />
Expand Down
7 changes: 5 additions & 2 deletions src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,12 @@ public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(Cancellation
var snapshots = await rpc.InvokeWithCancellationAsync<List<ResourceSnapshot>>(
"GetResourceSnapshotsAsync",
[],
cancellationToken).ConfigureAwait(false);
cancellationToken).ConfigureAwait(false) ?? [];

// Sort resources by name for consistent ordering.
snapshots.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));

return snapshots ?? [];
return snapshots;
}
catch (RemoteMethodNotFoundException ex)
{
Expand Down
40 changes: 20 additions & 20 deletions src/Aspire.Cli/Commands/DescribeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,27 +133,34 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
return ExitCodeConstants.Success;
}

var connection = result.Connection!;

// Get dashboard URL and resource snapshots in parallel before
// dispatching to the snapshot or watch path.
var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken);

await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);

var dashboardBaseUrl = (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)));

if (follow)
{
return await ExecuteWatchAsync(result.Connection!, resourceName, format, cancellationToken);
return await ExecuteWatchAsync(connection, snapshots, dashboardBaseUrl, resourceName, format, cancellationToken);
}
else
{
return await ExecuteSnapshotAsync(result.Connection!, resourceName, format, cancellationToken);
return ExecuteSnapshot(snapshots, dashboardBaseUrl, resourceName, format);
}
}

private async Task<int> ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
private int ExecuteSnapshot(IReadOnlyList<ResourceSnapshot> snapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format)
{
// Get dashboard URL and resource snapshots in parallel
var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken);

await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);

var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false);
var snapshots = await snapshotsTask.ConfigureAwait(false);

// Filter by resource name if specified
if (resourceName is not null)
{
Expand All @@ -167,8 +174,6 @@ private async Task<int> ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connec
return ExitCodeConstants.FailedToFindProject;
}

// Use the dashboard base URL if available
var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;
var resourceList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl);

if (format == OutputFormat.Json)
Expand All @@ -186,16 +191,11 @@ private async Task<int> ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connec
return ExitCodeConstants.Success;
}

private async Task<int> ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
private async Task<int> ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, IReadOnlyList<ResourceSnapshot> initialSnapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
{
// Get dashboard URL first for generating resource links
var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;

// 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)
{
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Cli/Commands/LogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
// Fetch snapshots for resource name resolution
var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).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)));

// Validate resource name exists (match by Name or DisplayName since users may pass either)
if (resourceName is not null)
{
Expand Down
22 changes: 20 additions & 2 deletions src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Aspire.Cli.Interaction;
using Aspire.Cli.Otlp;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Aspire.Otlp.Serialization;
Expand Down Expand Up @@ -209,8 +210,16 @@ public static async Task<ResourceInfoJson[]> GetAllResourcesAsync(HttpClient cli
var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false);
return resources!;
var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false) ?? [];

// Sort resources by name for consistent ordering.
Array.Sort(resources, (a, b) =>
{
var cmp = string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
return cmp != 0 ? cmp : string.Compare(a.InstanceId, b.InstanceId, StringComparison.OrdinalIgnoreCase);
});

return resources;
}

/// <summary>
Expand Down Expand Up @@ -321,6 +330,15 @@ public static IReadOnlyList<IOtlpResource> ToOtlpResources(ResourceInfoJson[] re
return result;
}

/// <summary>
/// Pre-resolves resource colors for all resources in sorted order so that
/// color assignment is deterministic regardless of encounter order in telemetry data.
/// </summary>
public static void ResolveResourceColors(ResourceColorMap colorMap, IReadOnlyList<IOtlpResource> allResources)
{
colorMap.ResolveAll(allResources.Select(r => OtlpHelpers.GetResourceName(r, allResources)));
}

/// <summary>
/// Resolves the display name for an OTLP resource using <see cref="OtlpHelpers.GetResourceName"/>,
/// appending a shortened instance ID when there are replicas with the same base name.
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ private async Task<int> FetchLogsAsync(

// Resolve resource name to specific instances (handles replicas)
var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Pre-resolve colors so assignment is deterministic regardless of data order
TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources);

// If a resource was specified but not found, return error
if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources))
Expand All @@ -126,8 +130,6 @@ private async Task<int> FetchLogsAsync(
return ExitCodeConstants.InvalidCommand;
}

var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>
{
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ private async Task<int> FetchSpansAsync(

// Resolve resource name to specific instances (handles replicas)
var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Pre-resolve colors so assignment is deterministic regardless of data order
TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources);

// If a resource was specified but not found, return error
if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources))
Expand All @@ -122,8 +126,6 @@ private async Task<int> FetchSpansAsync(
return ExitCodeConstants.InvalidCommand;
}

var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>
{
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ private async Task<int> FetchSingleTraceAsync(
var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Pre-resolve colors so assignment is deterministic regardless of data order
TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources);

var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId);

_logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url);
Expand Down Expand Up @@ -182,6 +185,9 @@ private async Task<int> FetchTracesAsync(

var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Pre-resolve colors so assignment is deterministic regardless of data order
TelemetryCommandHelpers.ResolveResourceColors(_resourceColorMap, allOtlpResources);

// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>();
if (hasError.HasValue)
Expand Down
75 changes: 49 additions & 26 deletions src/Aspire.Cli/Utils/ResourceColorMap.cs
Original file line number Diff line number Diff line change
@@ -1,43 +1,66 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Spectre.Console;

namespace Aspire.Cli.Utils;

/// <summary>
/// Assigns a consistent color to each resource name for colorized console output.
/// Colors are derived from the Aspire Dashboard's dark theme accent palette to provide
/// a consistent visual experience across the CLI and dashboard.
/// Colors are returned as hex strings (e.g. <c>#2CB7BD</c>) suitable for use in
/// Spectre.Console markup.
/// </summary>
internal sealed class ResourceColorMap
{
private static readonly Color[] s_resourceColors =
[
Color.Cyan1,
Color.Green,
Color.Yellow,
Color.Blue,
Color.Magenta1,
Color.Orange1,
Color.DeepPink1,
Color.SpringGreen1,
Color.Aqua,
Color.Violet
];
/// <summary>
/// Dark theme hex colors keyed by accent variable name from <see cref="ColorGenerator.s_variableNames"/>.
/// The keys must match the variable names exactly so that the same palette index maps to the same
/// visual color in both the CLI and the dashboard.
/// </summary>
internal static readonly Dictionary<string, string> s_hexColors = new()
{
["--accent-teal"] = "#2CB7BD",
["--accent-marigold"] = "#F3D58E",
["--accent-brass"] = "#BF8B64",
["--accent-peach"] = "#FFC18F",
["--accent-coral"] = "#F89170",
["--accent-royal-blue"] = "#88A1F0",
["--accent-orchid"] = "#E19AD4",
["--accent-brand-blue"] = "#1A7ECF",
["--accent-seafoam"] = "#74D6C6",
["--accent-mink"] = "#B9B2A4",
["--accent-cyan"] = "#17A0A6",
["--accent-gold"] = "#E3BA7A",
["--accent-bronze"] = "#8E6038",
["--accent-orange"] = "#FFA44A",
["--accent-rust"] = "#EA6A3E",
["--accent-navy"] = "#2A4C8A",
["--accent-berry"] = "#D150C3",
["--accent-ocean"] = "#16728F",
["--accent-jade"] = "#51C0A5",
["--accent-olive"] = "#847B63",
};

private readonly ColorGenerator _palette = new();

private readonly Dictionary<string, Color> _colorMap = new(StringComparers.ResourceName);
private int _nextColorIndex;
/// <summary>
/// Gets the hex color string assigned to the specified resource name, assigning a new one if first seen.
/// The returned value is a Spectre.Console markup-compatible hex color (e.g. <c>#2CB7BD</c>).
/// </summary>
public string GetColor(string resourceName)
{
var index = _palette.GetColorIndex(resourceName);
var variableName = ColorGenerator.s_variableNames[index];
return s_hexColors[variableName];
}

/// <summary>
/// Gets the color assigned to the specified resource name, assigning a new one if first seen.
/// Pre-resolves colors for all provided resource names in sorted order so that
/// color assignment is deterministic regardless of encounter order.
/// </summary>
public Color GetColor(string resourceName)
public void ResolveAll(IEnumerable<string> resourceNames)
{
if (!_colorMap.TryGetValue(resourceName, out var color))
{
color = s_resourceColors[_nextColorIndex % s_resourceColors.Length];
_colorMap[resourceName] = color;
_nextColorIndex++;
}
return color;
_palette.ResolveAll(resourceNames);
}
}

1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Aspire.Dashboard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@

<ItemGroup>
<Compile Include="$(SharedDir)ChannelExtensions.cs" Link="Extensions\ChannelExtensions.cs" />
<Compile Include="$(SharedDir)ColorGenerator.cs" Link="Utils\ColorGenerator.cs" />
<Compile Include="$(SharedDir)EnumerableExtensions.cs" Link="Utils\EnumerableExtensions.cs" />
<Compile Include="$(SharedDir)FormatHelpers.cs" Link="Utils\FormatHelpers.cs" />
<Compile Include="$(SharedDir)TimeProviderExtensions.cs" Link="Extensions\TimeProviderExtensions.cs" />
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Components/_Imports.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@using Microsoft.FluentUI.AspNetCore.Components
@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons
@using Microsoft.JSInterop
@using Aspire
@using Aspire.Dashboard
@using Aspire.Dashboard.Components
@using Aspire.Dashboard.Components.Controls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Encodings.Web;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Markdig.Renderers;
using Markdig.Renderers.Html;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using System.Collections.Immutable;
using System.Xml.Linq;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Resources;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
Expand Down
9 changes: 9 additions & 0 deletions src/Aspire.Dashboard/ServiceClient/DashboardClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,15 @@ private async Task<RetryResult> WatchResourcesAsync(RetryContext retryContext, C
{
throw new FormatException($"Unexpected {nameof(WatchResourcesUpdate)} kind: {response.KindCase}");
}

// Resolve resource colors for all resources so that color assignment is
// deterministic of order returned from the service, not order that the color for a resource is first used.
if (changes is not null)
{
var resolvedNames = _resourceByName.Values
.Select(r => ResourceViewModel.GetResourceName(r, _resourceByName));
ColorGenerator.Instance.ResolveAll(resolvedNames);
}
}

if (changes is not null)
Expand Down
Loading
Loading