Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3346ce8
Show waiting dependency details in dashboard
adamint May 14, 2026
3ac6f77
Add state description actions
adamint May 14, 2026
cec54b4
Use localized strings in state detail tests
adamint May 14, 2026
e9a8191
Refine state description start action
adamint May 14, 2026
f5e6cbb
Add waiting dependency coverage for all wait types
adamint May 14, 2026
3e277f1
Normalize state description punctuation
adamint May 14, 2026
cc42763
Align state description start action
adamint May 14, 2026
0dac353
Make state action fully inline
adamint May 14, 2026
bdbc841
Align inline state action icon
adamint May 14, 2026
0fae2e2
Allow BasketService HTTP health checks
adamint May 14, 2026
3818870
Rename state action to Start anyway
adamint May 14, 2026
39547d0
Include waiting dependencies in wait cancellation
adamint May 14, 2026
86751b5
Address waiting dependency dashboard feedback
adamint May 15, 2026
a1238ec
Address waiting dependency review feedback
adamint May 15, 2026
9c20407
Address waiting dependency review feedback
adamint May 18, 2026
23d6f2a
Merge main into waiting dependency details
adamint May 18, 2026
12452eb
Fix TestShop build after waiting dependency updates
adamint May 18, 2026
00dcf27
Remove manual TestShop start gate
adamint May 18, 2026
3126b2b
Fix waiting dependency property export
adamint May 18, 2026
d05e4da
Preserve structured resource properties in JSON
adamint May 18, 2026
8d13578
Merge remote-tracking branch 'microsoft/main' into dev/adamint/waitin…
adamint May 18, 2026
0679b59
Fix backchannel property test for JSON values
adamint May 19, 2026
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
5 changes: 0 additions & 5 deletions playground/TestShop/BasketService/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@
}
},
"AllowedHosts": "*",
"Kestrel": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

????

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed Kestrel HTTP/2-only default was blocking the new HTTP health check on BasketService. This playground change needs WithHttpHealthCheck("/health", endpointName: "http") so the dashboard can show healthy/starting dependency details for the waiting-state demo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but this breaks the basket service. It needs HTTP/2

Image

"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Aspire": {
"RabbitMQ": {
"Client": {
Expand Down
50 changes: 34 additions & 16 deletions playground/TestShop/TestShop.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
#endif

var catalogDbApp = builder.AddProject<Projects.CatalogDb>("catalogdbapp")
.WithReference(catalogDb);
.WithReference(catalogDb)
.WaitFor(catalogDb)
.WithHttpHealthCheck("/health");

if (builder.Environment.IsDevelopment() && builder.ExecutionContext.IsRunMode)
{
Expand All @@ -51,6 +53,8 @@

var catalogService = builder.AddProject<Projects.CatalogService>("catalogservice")
.WithReference(catalogDb)
.WaitFor(catalogDb)
.WaitFor(catalogDbApp)
Comment thread
adamint marked this conversation as resolved.
// Modify the endpoint URL
.WithUrlForEndpoint("https", u =>
{
Expand All @@ -65,6 +69,7 @@
})
// Hide the http URL
.WithUrlForEndpoint("http", u => u.DisplayLocation = UrlDisplayLocation.DetailsOnly)
.WithHttpHealthCheck("/health")
.WithReplicas(2);

var messaging = builder.AddRabbitMQ("messaging")
Expand All @@ -75,30 +80,43 @@

var basketService = builder.AddProject("basketservice", @"..\BasketService\BasketService.csproj")
.WithReference(basketCache)
.WithReference(messaging).WaitFor(messaging);
.WaitFor(basketCache)
.WithReference(messaging)
.WaitFor(messaging)
.WithHttpHealthCheck("/health", endpointName: "http");

var frontend = builder.AddProject<Projects.MyFrontend>("frontend")
.WithExternalHttpEndpoints()
.WithReference(basketService)
.WithReference(catalogService)
// Modify the display text of the URLs
.WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})"))
// Don't show the non-HTTPS link on the resources page (details only)
.WithUrlForEndpoint("http", url => url.DisplayLocation = UrlDisplayLocation.DetailsOnly)
// Add health relative URL (show in details only)
.WithUrlForEndpoint("https", ep => new() { Url = "/health", DisplayText = "Health", DisplayLocation = UrlDisplayLocation.DetailsOnly })
.WithHttpHealthCheck("/health");
.WithExternalHttpEndpoints()
.WithReference(basketService)
.WaitFor(basketService)
.WithReference(catalogService)
.WaitFor(catalogService)
// Modify the display text of the URLs
.WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})"))
// Don't show the non-HTTPS link on the resources page (details only)
.WithUrlForEndpoint("http", url => url.DisplayLocation = UrlDisplayLocation.DetailsOnly)
// Add health relative URL (show in details only)
.WithUrlForEndpoint("https", ep => new() { Url = "/health", DisplayText = "Health", DisplayLocation = UrlDisplayLocation.DetailsOnly })
.WithHttpHealthCheck("/health");

builder.AddProject<Projects.OrderProcessor>("orderprocessor", launchProfileName: "OrderProcessor")
.WithReference(messaging).WaitFor(messaging);
.WithReference(messaging)
.WaitFor(messaging);

#if YARP_USE_CONFIG_FILE
builder.AddYarp("apigateway")
.WithConfigFile("yarp.json")
.WithReference(basketService)
.WithReference(catalogService);
.WithConfigFile("yarp.json")
.WithReference(basketService)
.WaitFor(basketService)
.WithReference(catalogService)
.WaitFor(catalogService);
#else
var yarp = builder.AddYarp("apigateway");
yarp.WithReference(basketService)
.WaitFor(basketService)
.WithReference(catalogService)
.WaitFor(catalogService);

yarp.WithConfiguration(builder =>
{
// catalog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ namespace Aspire.Cli.Backchannel;
[JsonSerializable(typeof(IAsyncEnumerable<ResourceLogBatch>))]
[JsonSerializable(typeof(MessageFormatterEnumerableTracker.EnumeratorResults<ResourceLogBatch>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, JsonNode?>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(CapabilitiesInfo))]
Expand Down
66 changes: 64 additions & 2 deletions src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Nodes;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Aspire.Shared;
using Aspire.Shared.Model;
Expand Down Expand Up @@ -82,7 +84,9 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl

var properties = snapshot.Properties.OrderBy(p => p.Key).ToDistinctDictionary(
p => p.Key,
p => p.Value);
p => p.Value?.DeepClone());

var waitingFor = GetResolvedWaitingForDependencies(snapshot, allSnapshots);

// Build relationships by matching DisplayName
var relationships = new List<ResourceRelationshipJson>();
Expand Down Expand Up @@ -119,7 +123,10 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl
});

// Get source information using the shared ResourceSourceViewModel
var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties);
var stringProperties = snapshot.Properties.OrderBy(p => p.Key).ToDistinctDictionary(
p => p.Key,
p => ConvertJsonNodeToString(p.Value));
var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, stringProperties);

// Generate dashboard URL for this resource if a base URL is provided
string? dashboardUrl = null;
Expand All @@ -135,6 +142,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl
DisplayName = snapshot.DisplayName,
ResourceType = snapshot.ResourceType,
State = snapshot.State,
WaitingFor = waitingFor,
StateStyle = snapshot.StateStyle,
HealthStatus = snapshot.HealthStatus,
Source = sourceViewModel?.Value,
Expand All @@ -153,6 +161,60 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl
};
}

private static string[]? GetResolvedWaitingForDependencies(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> allSnapshots)
{
var waitingFor = snapshot.WaitingFor;
if (waitingFor is not { Length: > 0 } &&
snapshot.Properties.TryGetValue(KnownProperties.Resource.WaitingFor, out var waitingForProperty) &&
TryConvertJsonNodeToString(waitingForProperty, out var waitingForPropertyString) &&
!string.IsNullOrWhiteSpace(waitingForPropertyString))
{
waitingFor = waitingForPropertyString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

if (waitingFor is not { Length: > 0 })
{
return null;
}

var dependencies = new List<string>();
var seenDependencies = new HashSet<string>(StringComparers.ResourceName);

foreach (var dependency in waitingFor)
{
var dependencyName = dependency;
var matches = ResolveResources(dependency, allSnapshots);
if (matches.Count == 1)
{
dependencyName = GetResourceName(matches[0], allSnapshots);
}

if (seenDependencies.Add(dependencyName))
{
dependencies.Add(dependencyName);
}
}

return dependencies.Count > 0 ? [.. dependencies] : null;
}

private static string? ConvertJsonNodeToString(JsonNode? node)
{
return TryConvertJsonNodeToString(node, out var value) ? value : null;
}

private static bool TryConvertJsonNodeToString(JsonNode? node, [System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out string? value)
{
if (node is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var stringValue))
{
value = stringValue;
return true;
}

value = null;
return false;
}

internal static bool IsCommandAvailableToApi(ResourceSnapshotCommand command)
{
return string.Equals(command.State, "Enabled", StringComparison.OrdinalIgnoreCase) &&
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Commands/DescribeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
Expand Down Expand Up @@ -32,6 +33,8 @@ internal sealed class ResourcesOutput
[JsonSerializable(typeof(ResourceJson))]
[JsonSerializable(typeof(ResourceUrlJson))]
[JsonSerializable(typeof(ResourceVolumeJson))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(Dictionary<string, JsonNode?>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
[JsonSerializable(typeof(Dictionary<string, ResourceHealthReportJson>))]
[JsonSerializable(typeof(ResourceRelationshipJson))]
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Commands/PsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
Expand Down Expand Up @@ -44,6 +45,8 @@ internal sealed class AppHostDisplayInfo
[JsonSerializable(typeof(ResourceHealthReportJson))]
[JsonSerializable(typeof(ResourceCommandJson))]
[JsonSerializable(typeof(ResourceCommandArgumentJson[]))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(Dictionary<string, JsonNode?>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
[JsonSerializable(typeof(Dictionary<string, ResourceHealthReportJson>))]
[JsonSerializable(typeof(Dictionary<string, ResourceCommandJson>))]
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Shared.Model.Serialization;
Expand All @@ -15,6 +16,8 @@ namespace Aspire.Cli.Mcp.Tools;
[JsonSerializable(typeof(ResourceJson[]))]
[JsonSerializable(typeof(ResourceUrlJson))]
[JsonSerializable(typeof(ResourceVolumeJson))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(Dictionary<string, JsonNode?>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
[JsonSerializable(typeof(Dictionary<string, ResourceHealthReportJson>))]
[JsonSerializable(typeof(ResourceRelationshipJson))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ protected override void OnParametersSet()
{
Type = typeof(ResourceHealthStateValue),
Parameters = { ["Resource"] = _resource }
},
}
};

// For parameter resources whose value is unset, render the same "Value not set" affordance
Expand All @@ -225,7 +225,7 @@ protected override void OnParametersSet()
Parameters =
{
["Resource"] = _resource,
["OnExecuteCommandAsync"] = (Func<ResourceViewModel, CommandViewModel, Task>)ExecuteParameterCommandAsync,
["OnExecuteCommandAsync"] = (Func<ResourceViewModel, CommandViewModel, Task>)ExecuteResourceCommandAsync,
["IsCommandExecuting"] = IsCommandExecuting,
}
};
Expand Down Expand Up @@ -307,10 +307,11 @@ private void UpdateResourceActionsMenu()
IsCommandExecuting,
showViewDetails: false,
showConsoleLogsItem: true,
showUrls: true);
showUrls: true,
showStartCommand: false);
}

private async Task ExecuteParameterCommandAsync(ResourceViewModel resource, CommandViewModel command)
private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command)
{
await CommandSelected.InvokeAsync(command);
}
Expand Down Expand Up @@ -375,7 +376,7 @@ private List<DisplayedUrl> GetUrls()
private void AddStateDescriptionProperty(ResourceViewModel resource)
{
var stateViewModel = ResourceStateViewModel.GetStateViewModel(resource, ColumnsLoc);
var stateDescription = ResourceStateViewModel.GetResourceStateTooltip(resource, ColumnsLoc);
var stateDescription = ResourceStateViewModel.GetResourceStateTooltip(resource, ColumnsLoc, ResourceByName.Values);

if (string.IsNullOrWhiteSpace(stateDescription) || string.Equals(stateDescription, stateViewModel.Text, StringComparison.Ordinal))
{
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/Resources.razor
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@
<ResourceNameDisplay Resource="context.Resource" FilterText="@_filter" FormatName="GetResourceName" />
</span>
</AspireTemplateColumn>
<AspireTemplateColumn ColumnId="@StateColumn" ColumnManager="@_manager" Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesStateColumnHeader)]" Sortable="true" SortBy="@_stateSort" Tooltip="true" TooltipText="@(c => ResourceStateViewModel.GetResourceStateTooltip(c.Resource, ColumnsLoc))">
<AspireTemplateColumn ColumnId="@StateColumn" ColumnManager="@_manager" Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesStateColumnHeader)]" Sortable="true" SortBy="@_stateSort" Tooltip="true" TooltipText="@(c => ResourceStateViewModel.GetResourceStateTooltip(c.Resource, ColumnsLoc, _resourceByName.Values))">
<StateColumnDisplay Resource="@context.Resource" UnviewedErrorCounts="@_resourceUnviewedErrorCounts" />
</AspireTemplateColumn>
<AspireTemplateColumn ColumnId="@StartTimeColumn" ColumnManager="@_manager" Title="@Loc[nameof(Dashboard.Resources.Resources.ResourcesStartTimeColumnHeader)]" Sortable="true" SortBy="@_startTimeSort" TooltipText="@(context => context.Resource.StartTimeStamp != null ? FormatHelpers.FormatDateTime(TimeProvider, context.Resource.StartTimeStamp.Value, MillisecondsDisplay.None, CultureInfo.CurrentCulture) : null)" Tooltip="true">
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ internal static string GetResponseGraphJson(List<ResourceViewModel> resources, s
["resourceName"] = resourceName,
["type"] = resource.ResourceType,
["state"] = resource.State,
["stateDescription"] = ResourceStateViewModel.GetResourceStateTooltip(resource, s_columnsLoc),
["stateDescription"] = ResourceStateViewModel.GetResourceStateTooltip(resource, s_columnsLoc, resources),
["relationships"] = GetResourceRelationshipsJson(resources, resource, getResourceName),
["endpointUrls"] = endpointUrlsArray,
["health"] = healthObj,
Expand Down
8 changes: 5 additions & 3 deletions src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ public void AddMenuItems(
Func<ResourceViewModel, CommandViewModel, bool> isCommandExecuting,
bool showViewDetails,
bool showConsoleLogsItem,
bool showUrls)
bool showUrls,
bool showStartCommand = true)
{
if (showViewDetails)
{
Expand Down Expand Up @@ -165,7 +166,7 @@ await _aiContextProvider.LaunchAssistantSidebarAsync(

AddTelemetryMenuItems(menuItems, resource, resourceByName);

AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting);
AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting, showStartCommand);

if (showUrls)
{
Expand Down Expand Up @@ -282,9 +283,10 @@ private void AddTelemetryMenuItems(List<MenuButtonItem> menuItems, ResourceViewM
}
}

private void AddCommandMenuItems(List<MenuButtonItem> menuItems, ResourceViewModel resource, EventCallback<CommandViewModel> commandSelected, Func<ResourceViewModel, CommandViewModel, bool> isCommandExecuting)
private void AddCommandMenuItems(List<MenuButtonItem> menuItems, ResourceViewModel resource, EventCallback<CommandViewModel> commandSelected, Func<ResourceViewModel, CommandViewModel, bool> isCommandExecuting, bool showStartCommand)
{
var menuCommands = resource.Commands
.Where(c => showStartCommand || !c.Name.Equals(CommandViewModel.StartCommand, StringComparisons.CommandName))
Comment thread
adamint marked this conversation as resolved.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this still here? The start command is not added to the menu:

Image

.Where(c => c.State != CommandViewModelState.Hidden)
.ToList();

Expand Down
9 changes: 8 additions & 1 deletion src/Aspire.Dashboard/Model/ResourceStateViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ private static (Icon icon, Color color) GetStateIcon(ResourceViewModel resource)
/// <remarks>
/// This is a static method so it can be called at the level of the parent column.
/// </remarks>
internal static string GetResourceStateTooltip(ResourceViewModel resource, IStringLocalizer<Columns> loc)
internal static string GetResourceStateTooltip(ResourceViewModel resource, IStringLocalizer<Columns> loc, IEnumerable<ResourceViewModel>? allResources = null)
{
if (resource.IsFailedToStart())
{
Expand Down Expand Up @@ -132,6 +132,13 @@ internal static string GetResourceStateTooltip(ResourceViewModel resource, IStri
}
else if (resource.IsWaiting())
{
if (allResources is not null
? resource.TryGetResolvedWaitingForDependencies(allResources, out var dependencies)
: resource.TryGetWaitingForDependencies(out dependencies))
{
return loc.GetString(nameof(Columns.StateColumnResourceWaitingFor), string.Join(", ", dependencies));
}

return loc[nameof(Columns.StateColumnResourceWaiting)];
}
else if (resource.IsNotStarted())
Expand Down
Loading
Loading