diff --git a/playground/TestShop/BasketService/appsettings.json b/playground/TestShop/BasketService/appsettings.json index 0c784605fe0..3b76c293572 100644 --- a/playground/TestShop/BasketService/appsettings.json +++ b/playground/TestShop/BasketService/appsettings.json @@ -7,11 +7,6 @@ } }, "AllowedHosts": "*", - "Kestrel": { - "EndpointDefaults": { - "Protocols": "Http2" - } - }, "Aspire": { "RabbitMQ": { "Client": { diff --git a/playground/TestShop/TestShop.AppHost/AppHost.cs b/playground/TestShop/TestShop.AppHost/AppHost.cs index 7e5a16ccb15..a55a391f4ea 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -29,7 +29,9 @@ #endif var catalogDbApp = builder.AddProject("catalogdbapp") - .WithReference(catalogDb); + .WithReference(catalogDb) + .WaitFor(catalogDb) + .WithHttpHealthCheck("/health"); if (builder.Environment.IsDevelopment() && builder.ExecutionContext.IsRunMode) { @@ -51,6 +53,8 @@ var catalogService = builder.AddProject("catalogservice") .WithReference(catalogDb) + .WaitFor(catalogDb) + .WaitFor(catalogDbApp) // Modify the endpoint URL .WithUrlForEndpoint("https", u => { @@ -65,6 +69,7 @@ }) // Hide the http URL .WithUrlForEndpoint("http", u => u.DisplayLocation = UrlDisplayLocation.DetailsOnly) + .WithHttpHealthCheck("/health") .WithReplicas(2); var messaging = builder.AddRabbitMQ("messaging") @@ -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("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("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 diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 317a04bf83b..57f054fad81 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -56,6 +56,7 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(IAsyncEnumerable))] [JsonSerializable(typeof(MessageFormatterEnumerableTracker.EnumeratorResults))] [JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(CapabilitiesInfo))] diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index aa5c4f0a440..c0eb1ee526b 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -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; @@ -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(); @@ -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; @@ -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, @@ -153,6 +161,60 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }; } + private static string[]? GetResolvedWaitingForDependencies(ResourceSnapshot snapshot, IReadOnlyList 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(); + var seenDependencies = new HashSet(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(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) && diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index d98f6da2767..86d06558656 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -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; @@ -32,6 +33,8 @@ internal sealed class ResourcesOutput [JsonSerializable(typeof(ResourceJson))] [JsonSerializable(typeof(ResourceUrlJson))] [JsonSerializable(typeof(ResourceVolumeJson))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ResourceRelationshipJson))] diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index 3f215af21ed..e33114cfa87 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -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; @@ -44,6 +45,8 @@ internal sealed class AppHostDisplayInfo [JsonSerializable(typeof(ResourceHealthReportJson))] [JsonSerializable(typeof(ResourceCommandJson))] [JsonSerializable(typeof(ResourceCommandArgumentJson[]))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index ca0166557fd..a07bafaa03a 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -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; @@ -15,6 +16,8 @@ namespace Aspire.Cli.Mcp.Tools; [JsonSerializable(typeof(ResourceJson[]))] [JsonSerializable(typeof(ResourceUrlJson))] [JsonSerializable(typeof(ResourceVolumeJson))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ResourceRelationshipJson))] diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index 6bd77acb47e..b3803a39940 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -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 @@ -225,7 +225,7 @@ protected override void OnParametersSet() Parameters = { ["Resource"] = _resource, - ["OnExecuteCommandAsync"] = (Func)ExecuteParameterCommandAsync, + ["OnExecuteCommandAsync"] = (Func)ExecuteResourceCommandAsync, ["IsCommandExecuting"] = IsCommandExecuting, } }; @@ -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); } @@ -375,7 +376,7 @@ private List 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)) { diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index eea6841db6c..25b8a0500c0 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -158,7 +158,7 @@ - + diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index fbb1c1e655e..5fe439047a6 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -106,7 +106,7 @@ internal static string GetResponseGraphJson(List 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, diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index b8412e42c29..525b628c7a6 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -79,7 +79,8 @@ public void AddMenuItems( Func isCommandExecuting, bool showViewDetails, bool showConsoleLogsItem, - bool showUrls) + bool showUrls, + bool showStartCommand = true) { if (showViewDetails) { @@ -165,7 +166,7 @@ await _aiContextProvider.LaunchAssistantSidebarAsync( AddTelemetryMenuItems(menuItems, resource, resourceByName); - AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting); + AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting, showStartCommand); if (showUrls) { @@ -282,9 +283,10 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM } } - private void AddCommandMenuItems(List menuItems, ResourceViewModel resource, EventCallback commandSelected, Func isCommandExecuting) + private void AddCommandMenuItems(List menuItems, ResourceViewModel resource, EventCallback commandSelected, Func isCommandExecuting, bool showStartCommand) { var menuCommands = resource.Commands + .Where(c => showStartCommand || !c.Name.Equals(CommandViewModel.StartCommand, StringComparisons.CommandName)) .Where(c => c.State != CommandViewModelState.Hidden) .ToList(); diff --git a/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs index 29e890428eb..f4e641a93e8 100644 --- a/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs @@ -101,7 +101,7 @@ private static (Icon icon, Color color) GetStateIcon(ResourceViewModel resource) /// /// This is a static method so it can be called at the level of the parent column. /// - internal static string GetResourceStateTooltip(ResourceViewModel resource, IStringLocalizer loc) + internal static string GetResourceStateTooltip(ResourceViewModel resource, IStringLocalizer loc, IEnumerable? allResources = null) { if (resource.IsFailedToStart()) { @@ -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()) diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs index 3a902c36928..d90ae18fbad 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs @@ -99,6 +99,58 @@ public static bool TryGetAppArgsSensitivity(this ResourceViewModel resource, out return resource.TryGetCustomDataBoolArray(KnownProperties.Resource.AppArgsSensitivity, out argParams); } + public static bool TryGetWaitingForDependencies(this ResourceViewModel resource, out ImmutableArray dependencies) + { + return resource.TryGetCustomDataStringArray(KnownProperties.Resource.WaitingFor, out dependencies) && dependencies.Length > 0; + } + + public static bool TryGetResolvedWaitingForDependencies( + this ResourceViewModel resource, + IEnumerable allResources, + out ImmutableArray dependencies) + { + if (!resource.TryGetWaitingForDependencies(out var waitingForDependencies)) + { + dependencies = default; + return false; + } + + var resources = allResources.ToArray(); + var builder = ImmutableArray.CreateBuilder(); + var seenDependencies = new HashSet(StringComparers.ResourceName); + + foreach (var dependency in waitingForDependencies) + { + var resolvedDependency = dependency; + var matchingResource = resources.FirstOrDefault(r => string.Equals(r.Name, dependency, StringComparisons.ResourceName)); + if (matchingResource is null) + { + var matchingResources = resources + .Where(r => string.Equals(r.DisplayName, dependency, StringComparisons.ResourceName)) + .Take(2) + .ToArray(); + + if (matchingResources.Length == 1) + { + matchingResource = matchingResources[0]; + } + } + + if (matchingResource is not null) + { + resolvedDependency = ResourceViewModel.GetResourceName(matchingResource, resources); + } + + if (seenDependencies.Add(resolvedDependency)) + { + builder.Add(resolvedDependency); + } + } + + dependencies = builder.ToImmutable(); + return dependencies.Length > 0; + } + private static bool TryGetCustomDataString(this ResourceViewModel resource, string key, [NotNullWhen(returnValue: true)] out string? s) { if (resource.Properties.TryGetValue(key, out var property) && property.Value.TryConvertToString(out var valueString)) diff --git a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs index e7a6d3fb4b8..4b8ff531711 100644 --- a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs +++ b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs @@ -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.Shared.Model.Serialization; @@ -19,6 +20,8 @@ namespace Aspire.Dashboard.Model.Serialization; [JsonSerializable(typeof(ResourceJson))] [JsonSerializable(typeof(ResourceUrlJson))] [JsonSerializable(typeof(ResourceVolumeJson))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ResourceRelationshipJson))] diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index cc36a0eaa12..1bff9d20fc6 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Dashboard.Model.Serialization; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -725,6 +726,7 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea ResourceType = resource.ResourceType, Uid = resource.Uid, State = resource.State, + WaitingFor = resource.TryGetResolvedWaitingForDependencies(allResources, out var waitingForDependencies) ? [.. waitingForDependencies] : null, CreationTimestamp = resource.CreationTimeStamp, StartTimestamp = resource.StartTimeStamp, StopTimestamp = resource.StopTimeStamp, @@ -762,7 +764,7 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea Properties = resource.Properties.Count > 0 ? resource.Properties.OrderBy(p => p.Key).ToDistinctDictionary( p => p.Key, - p => p.Value.Value.TryConvertToString(out var value) ? value : null) + p => ConvertPropertyValueToJsonNode(p.Value.Value)) : null, Relationships = relationshipsJson, Commands = resource.Commands.Length > 0 @@ -783,6 +785,27 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea return resourceJson; } + private static JsonNode? ConvertPropertyValueToJsonNode(Google.Protobuf.WellKnownTypes.Value value) + { + if (value.TryConvertToString(out var stringValue)) + { + return JsonValue.Create(stringValue); + } + + if (value.ListValue is not null) + { + var array = new JsonArray(); + foreach (var element in value.ListValue.Values) + { + array.Add(ConvertPropertyValueToJsonNode(element)); + } + + return array; + } + + return null; + } + /// /// Gets the destination name for a span by resolving uninstrumented peer names. /// diff --git a/src/Aspire.Dashboard/Resources/Columns.Designer.cs b/src/Aspire.Dashboard/Resources/Columns.Designer.cs index 6e2d1f34ebf..f0ccf268453 100644 --- a/src/Aspire.Dashboard/Resources/Columns.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Columns.Designer.cs @@ -100,7 +100,7 @@ public static string SourceColumnDisplayCopyCommandToClipboard { /// /// Looks up a localized string similar to Container runtime was found but appears to be unhealthy. Ensure that it is running. - ///For more information, see https://aka.ms/aspire/container-runtime-unhealthy. + ///For more information, see https://aka.ms/aspire/container-runtime-unhealthy.. /// public static string StateColumnResourceContainerRuntimeUnhealthy { get { @@ -109,7 +109,7 @@ public static string StateColumnResourceContainerRuntimeUnhealthy { } /// - /// Looks up a localized string similar to {0} is no longer running. + /// Looks up a localized string similar to {0} is no longer running.. /// public static string StateColumnResourceExited { get { @@ -118,7 +118,7 @@ public static string StateColumnResourceExited { } /// - /// Looks up a localized string similar to {0} failed to start. + /// Looks up a localized string similar to {0} failed to start.. /// public static string StateColumnResourceFailedToStart { get { @@ -127,7 +127,7 @@ public static string StateColumnResourceFailedToStart { } /// - /// Looks up a localized string similar to {0} exited unexpectedly with exit code {1}. + /// Looks up a localized string similar to {0} exited unexpectedly with exit code {1}.. /// public static string StateColumnResourceExitedUnexpectedly { get { @@ -136,7 +136,7 @@ public static string StateColumnResourceExitedUnexpectedly { } /// - /// Looks up a localized string similar to Resource has not started because it's configured to not automatically start.. + /// Looks up a localized string similar to Resource is not configured to start automatically.. /// public static string StateColumnResourceNotStarted { get { @@ -145,13 +145,22 @@ public static string StateColumnResourceNotStarted { } /// - /// Looks up a localized string similar to Resource is waiting for other resources to be in a running and healthy state.. + /// Looks up a localized string similar to Resource is waiting for dependencies.. /// public static string StateColumnResourceWaiting { get { return ResourceManager.GetString("StateColumnResourceWaiting", resourceCulture); } } + + /// + /// Looks up a localized string similar to Waiting for dependencies: {0}.. + /// + public static string StateColumnResourceWaitingFor { + get { + return ResourceManager.GetString("StateColumnResourceWaitingFor", resourceCulture); + } + } /// /// Looks up a localized string similar to Unknown. diff --git a/src/Aspire.Dashboard/Resources/Columns.resx b/src/Aspire.Dashboard/Resources/Columns.resx index 78eda3e8a9d..b000d81ece3 100644 --- a/src/Aspire.Dashboard/Resources/Columns.resx +++ b/src/Aspire.Dashboard/Resources/Columns.resx @@ -118,15 +118,15 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - {0} exited unexpectedly with exit code {1} + {0} exited unexpectedly with exit code {1}. {0} is a resource type, {1} is a number - {0} is no longer running + {0} is no longer running. {0} is a resource type - {0} failed to start + {0} failed to start. {0} is a resource type @@ -155,13 +155,17 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Contains a new line - Resource has not started because it's configured to not automatically start. + Resource is not configured to start automatically. - Resource is waiting for other resources to be in a running and healthy state. + Resource is waiting for dependencies. + + + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 5e66f7765e2..ff2df96724a 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -860,7 +860,7 @@ public static string ResourceDetailsStateDescriptionHeader { return ResourceManager.GetString("ResourceDetailsStateDescriptionHeader", resourceCulture); } } - + /// /// Looks up a localized string similar to Type. /// diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf index ad8a5494870..18798537364 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Modul runtime kontejneru byl nalezen, ale zdá se, že není v pořádku. Ujistěte se, že je spuštěný. Další informace najdete na https://aka.ms/dotnet/aspire/container-runtime-unhealthy. Contains a new line - {0} is no longer running - {0} už neběží. + {0} is no longer running. + {0} už neběží. {0} is a resource type - {0} exited unexpectedly with exit code {1} - Prostředek {0} byl neočekávaně ukončen s ukončovacím kódem {1}. + {0} exited unexpectedly with exit code {1}. + Prostředek {0} byl neočekávaně ukončen s ukončovacím kódem {1}. {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - Prostředek se nespustil, protože je nakonfigurovaný tak, aby se nespouštěl automaticky. + Resource is not configured to start automatically. + Prostředek se nespustil, protože je nakonfigurovaný tak, aby se nespouštěl automaticky. - Resource is waiting for other resources to be in a running and healthy state. - Prostředek čeká, až budou ostatní prostředky ve stavu Spuštěno nebo V pořádku. + Resource is waiting for dependencies. + Prostředek čeká, až budou ostatní prostředky ve stavu Spuštěno nebo V pořádku. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Neznámé diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf index 15961b2451b..0306b99fef7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Die Containerruntime wurde gefunden, scheint aber fehlerhaft zu sein. Stellen Sie sicher, dass sie ausgeführt wird. Weitere Informationen finden Sie unter https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} wird nicht mehr ausgeführt. + {0} is no longer running. + {0} wird nicht mehr ausgeführt. {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} wurde unerwartet mit dem Exitcode {1} beendet. + {0} exited unexpectedly with exit code {1}. + {0} wurde unerwartet mit dem Exitcode {1} beendet. {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - Die Ressource wurde nicht gestartet, weil sie so eingestellt ist, dass sie nicht automatisch startet. + Resource is not configured to start automatically. + Die Ressource wurde nicht gestartet, weil sie so eingestellt ist, dass sie nicht automatisch startet. - Resource is waiting for other resources to be in a running and healthy state. - Die Ressource wartet darauf, dass andere Ressourcen in einem fehlerfreien Zustand ausgeführt werden. + Resource is waiting for dependencies. + Die Ressource wartet darauf, dass andere Ressourcen in einem fehlerfreien Zustand ausgeführt werden. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Unbekannt diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf index dbd7caed689..e5aa2e61afd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Se encontró el runtime de contenedor, pero parece que está en un estado incorrecto. Asegúrese de que se está ejecutando. Para obtener más información, consulte https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} ya no se está ejecutando + {0} is no longer running. + {0} ya no se está ejecutando {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} se cerró inesperadamente con el código de salida {1} + {0} exited unexpectedly with exit code {1}. + {0} se cerró inesperadamente con el código de salida {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - El recurso no se ha iniciado porque está configurado para no iniciarse automáticamente. + Resource is not configured to start automatically. + El recurso no se ha iniciado porque está configurado para no iniciarse automáticamente. - Resource is waiting for other resources to be in a running and healthy state. - El recurso está esperando a que otros recursos estén en ejecución y en buen estado. + Resource is waiting for dependencies. + El recurso está esperando a que otros recursos estén en ejecución y en buen estado. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Desconocido diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf index 1144986e484..e096e5f97ca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Le runtime de conteneur a été trouvé, mais il semble qu’il ne soit pas sain. Assurez-vous qu’il est en cours d’exécution. Pour plus d’informations, consultez https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} n’est plus en cours d’exécution. + {0} is no longer running. + {0} n’est plus en cours d’exécution. {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} s’est arrêté de manière inattendue avec le code {1} + {0} exited unexpectedly with exit code {1}. + {0} s’est arrêté de manière inattendue avec le code {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - La ressource n’a pas démarré, car elle est configurée pour ne pas démarrer automatiquement. + Resource is not configured to start automatically. + La ressource n’a pas démarré, car elle est configurée pour ne pas démarrer automatiquement. - Resource is waiting for other resources to be in a running and healthy state. - La ressource attend que les autres ressources soient dans un état d’exécution et sain. + Resource is waiting for dependencies. + La ressource attend que les autres ressources soient dans un état d’exécution et sain. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Inconnu diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf index e151441083b..84d878a14c0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. È stato trovato il runtime del contenitore, ma non è integro. Verificare che sia in esecuzione. Per altre informazioni, vedere https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} non è più in esecuzione + {0} is no longer running. + {0} non è più in esecuzione {0} is a resource type - {0} exited unexpectedly with exit code {1} - La risorsa {0} è stata chiusa in modo imprevisto con codice di uscita {1} + {0} exited unexpectedly with exit code {1}. + La risorsa {0} è stata chiusa in modo imprevisto con codice di uscita {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - La risorsa non è stata avviata perché è configurata per non avviarsi automaticamente. + Resource is not configured to start automatically. + La risorsa non è stata avviata perché è configurata per non avviarsi automaticamente. - Resource is waiting for other resources to be in a running and healthy state. - La risorsa è in attesa che altre risorse siano in esecuzione e in stato integro. + Resource is waiting for dependencies. + La risorsa è in attesa che altre risorse siano in esecuzione e in stato integro. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Sconosciuto diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf index f6baab7153b..d7715df2ac2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. コンテナー ランタイムが見つかりましたが、異常である可能性があります。実行中であることを確認してください。 詳細については、https://aka.ms/dotnet/aspire/container-runtime-unhealthy を参照してください。 Contains a new line - {0} is no longer running - {0} が実行されなくなりました + {0} is no longer running. + {0} が実行されなくなりました {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} は終了コード{1} で予期せず終了しました + {0} exited unexpectedly with exit code {1}. + {0} は終了コード{1} で予期せず終了しました {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - リソースは自動的に開始しないように構成されているため、開始されていません。 + Resource is not configured to start automatically. + リソースは自動的に開始しないように構成されているため、開始されていません。 - Resource is waiting for other resources to be in a running and healthy state. - リソースは、他のリソースが実行中で正常な状態になるのを待機しています。 + Resource is waiting for dependencies. + リソースは、他のリソースが実行中で正常な状態になるのを待機しています。 + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown 不明 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf index 297ab007db8..c2b854e278a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. 컨테이너 런타임을 찾았지만 비정상 상태인 것 같습니다. 실행 중인지 확인하세요. 자세한 내용은 https://aka.ms/dotnet/aspire/container-runtime-unhealthy 페이지를 참조하세요. Contains a new line - {0} is no longer running - {0}(이)가 더 이상 실행되고 있지 않습니다. + {0} is no longer running. + {0}(이)가 더 이상 실행되고 있지 않습니다. {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0}(이)가 종료 코드 {1}(으)로 예기치 않게 종료되었습니다. + {0} exited unexpectedly with exit code {1}. + {0}(이)가 종료 코드 {1}(으)로 예기치 않게 종료되었습니다. {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - 리소스가 자동으로 시작되지 않도록 구성되어 있어 시작되지 않았습니다. + Resource is not configured to start automatically. + 리소스가 자동으로 시작되지 않도록 구성되어 있어 시작되지 않았습니다. - Resource is waiting for other resources to be in a running and healthy state. - 해당 리소스는 다른 리소스가 실행 중이며 정상 상태가 될 때까지 대기하고 있습니다. + Resource is waiting for dependencies. + 해당 리소스는 다른 리소스가 실행 중이며 정상 상태가 될 때까지 대기하고 있습니다. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown 알 수 없음 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf index 6bdc4222dbc..75494e65117 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Znaleziono środowisko uruchomieniowe kontenera, ale wygląda na to, że jest w złej kondycji. Upewnij się, że jest uruchomione. Aby uzyskać więcej informacji, zobacz https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - Wątek {0} już nie działa + {0} is no longer running. + Wątek {0} już nie działa {0} is a resource type - {0} exited unexpectedly with exit code {1} - Nieoczekiwanie zakończony {0} z kodem zakończenia {1} + {0} exited unexpectedly with exit code {1}. + Nieoczekiwanie zakończony {0} z kodem zakończenia {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - Zasób nie został uruchomiony, ponieważ jest skonfigurowany tak, aby nie uruchamiał się automatycznie. + Resource is not configured to start automatically. + Zasób nie został uruchomiony, ponieważ jest skonfigurowany tak, aby nie uruchamiał się automatycznie. - Resource is waiting for other resources to be in a running and healthy state. - Zasób oczekuje, aż inne zasoby będą w stanie uruchomionym i w dobrej kondycji. + Resource is waiting for dependencies. + Zasób oczekuje, aż inne zasoby będą w stanie uruchomionym i w dobrej kondycji. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Nieznane diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf index baf23f02e45..b19bfd1d788 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. O runtime do contêiner foi encontrado, mas parece não íntegro. Certifique-se de que ele esteja em execução. Para obter mais informações, consulte https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} não está mais em execução + {0} is no longer running. + {0} não está mais em execução {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} saiu inesperadamente com o código de saída {1} + {0} exited unexpectedly with exit code {1}. + {0} saiu inesperadamente com o código de saída {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - O recurso não foi iniciado porque está configurado para não iniciar automaticamente. + Resource is not configured to start automatically. + O recurso não foi iniciado porque está configurado para não iniciar automaticamente. - Resource is waiting for other resources to be in a running and healthy state. - O recurso está aguardando outros recursos estarem em execução e em um estado íntegro. + Resource is waiting for dependencies. + O recurso está aguardando outros recursos estarem em execução e em um estado íntegro. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Desconhecido diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf index 6ad04c2158c..7d14d2f76af 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Среда выполнения контейнера обнаружена, но, по-видимому, неработоспособна. Убедитесь, что она запущена. Дополнительные сведения см. по адресу https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} больше не выполняется. + {0} is no longer running. + {0} больше не выполняется. {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} неожиданно завершила работу, вернув код {1}. + {0} exited unexpectedly with exit code {1}. + {0} неожиданно завершила работу, вернув код {1}. {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - Ресурс не запущен, поскольку он настроен на отключение автоматического запуска. + Resource is not configured to start automatically. + Ресурс не запущен, поскольку он настроен на отключение автоматического запуска. - Resource is waiting for other resources to be in a running and healthy state. - Ресурс ожидает приведения других ресурсов в работоспособное состояние. + Resource is waiting for dependencies. + Ресурс ожидает приведения других ресурсов в работоспособное состояние. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Неизвестно diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf index c2d52ffb531..18e98b0cd30 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. Kapsayıcı çalışma zamanı bulundu, ancak iyi durumda değil. Çalışır durumda olduğundan emin olun. Daha fazla bilgi için bkz. https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} artık çalışmıyor + {0} is no longer running. + {0} artık çalışmıyor {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} {1} çıkış kodu ile beklenmedik bir şekilde çıktı + {0} exited unexpectedly with exit code {1}. + {0} {1} çıkış kodu ile beklenmedik bir şekilde çıktı {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - Kaynak, otomatik olarak başlatılmak için yapılandırılmadığından başlatılmadı. + Resource is not configured to start automatically. + Kaynak, otomatik olarak başlatılmak için yapılandırılmadığından başlatılmadı. - Resource is waiting for other resources to be in a running and healthy state. - Kaynak, diğer kaynakların çalışır durumda ve sağlıklı olmasını bekliyor. + Resource is waiting for dependencies. + Kaynak, diğer kaynakların çalışır durumda ve sağlıklı olmasını bekliyor. + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown Bilinmiyor diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf index 556469697e2..64fbb66adf3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. 已找到容器运行时,但它似乎运行不正常。请确保它正在运行。 有关详细信息,请参阅 https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} 已不再运行 + {0} is no longer running. + {0} 已不再运行 {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} 已意外退出,退出代码为 {1} + {0} exited unexpectedly with exit code {1}. + {0} 已意外退出,退出代码为 {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - 资源尚未启动,因为它配置为不自动启动。 + Resource is not configured to start automatically. + 资源尚未启动,因为它配置为不自动启动。 - Resource is waiting for other resources to be in a running and healthy state. - 资源正在等待其他资源处于运行正常状态。 + Resource is waiting for dependencies. + 资源正在等待其他资源处于运行正常状态。 + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown 未知 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf index 4e2fc151cca..cf4dfcbf96c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf @@ -19,36 +19,41 @@ Container runtime was found but appears to be unhealthy. Ensure that it is running. -For more information, see https://aka.ms/aspire/container-runtime-unhealthy +For more information, see https://aka.ms/aspire/container-runtime-unhealthy. 找到容器執行階段,但似乎狀況不良。確保它執行中。 如需詳細資訊,請參閱 https://aka.ms/dotnet/aspire/container-runtime-unhealthy Contains a new line - {0} is no longer running - {0} 不再執行 + {0} is no longer running. + {0} 不再執行 {0} is a resource type - {0} exited unexpectedly with exit code {1} - {0} 意外結束,結束代碼: {1} + {0} exited unexpectedly with exit code {1}. + {0} 意外結束,結束代碼: {1} {0} is a resource type, {1} is a number - {0} failed to start - {0} failed to start + {0} failed to start. + {0} failed to start. {0} is a resource type - Resource has not started because it's configured to not automatically start. - 資源尚未啟動,因為它已設定為不會自動啟動。 + Resource is not configured to start automatically. + 資源尚未啟動,因為它已設定為不會自動啟動。 - Resource is waiting for other resources to be in a running and healthy state. - 資源正在等待其他資源處於執行中且狀態良好。 + Resource is waiting for dependencies. + 資源正在等待其他資源處於執行中且狀態良好。 + + Waiting for dependencies: {0}. + Waiting for dependencies: {0}. + {0} is a comma-separated list of dependency resource names. + Unknown 未知 diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index aea6eb244cb..98030138be0 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -139,7 +139,7 @@ public async Task WaitForResourceAsync(string resourceName, IEnumerable< return finalState; } - private async Task WaitUntilHealthyAsync(IResource resource, IResource dependency, WaitBehavior waitBehavior, CancellationToken cancellationToken) + private async Task WaitUntilHealthyAsync(IResource resource, IResource dependency, WaitBehavior waitBehavior, CancellationToken cancellationToken, Func? onDependencyReady = null) { using var activity = ProfilingTelemetry.StartResourceWaitForDependency(Configuration, resource, dependency, WaitType.WaitUntilHealthy, waitBehavior); @@ -173,7 +173,7 @@ await WaitForResourceCoreAsync( await resourceEvent.Snapshot.ResourceReadyEvent!.EventTask.WaitAsync(cancellationToken).ConfigureAwait(false); resourceLogger.LogInformation("Finished waiting for resource '{ResourceName}'.", displayName); - }, cancellationToken).ConfigureAwait(false); + }, cancellationToken, onDependencyReady).ConfigureAwait(false); } catch (Exception ex) { @@ -279,7 +279,7 @@ internal static bool ShouldYieldHealthyWait(WaitBehavior waitBehavior, CustomRes _ => throw new DistributedApplicationException($"Unexpected wait behavior: {waitBehavior}") }; - private async Task WaitUntilCompletionAsync(IResource resource, IResource dependency, int exitCode, CancellationToken cancellationToken) + private async Task WaitUntilCompletionAsync(IResource resource, IResource dependency, int exitCode, CancellationToken cancellationToken, Func? onDependencyReady = null) { using var activity = ProfilingTelemetry.StartResourceWaitForDependency(Configuration, resource, dependency, WaitType.WaitForCompletion, waitBehavior: null); activity.SetResourceWaitExpectedExitCode(exitCode); @@ -290,17 +290,6 @@ private async Task WaitUntilCompletionAsync(IResource resource, IResource depend var resourceLogger = _resourceLoggerService.GetLogger(resource); resourceLogger.LogInformation("Waiting for resource '{ResourceName}' to complete.", dependency.Name); - // Only transition replicas that are actually starting up to "Waiting". - // Replicas already in a Running or terminal state should not be clobbered, - // as this broadcast targets ALL replicas of the resource (model-level update), - // not just the specific replica being started. - await PublishUpdateAsync(resource, s => - s.State?.Text is null - || s.State?.Text == KnownResourceStates.Starting - || s.State?.Text == KnownResourceStates.Waiting - ? s with { State = KnownResourceStates.Waiting } - : s).ConfigureAwait(false); - for (var i = 0; i < names.Length; i++) { var displayName = names.Length > 1 ? names[i] : dependency.Name; @@ -353,6 +342,11 @@ async Task Core(string displayName, string resourceId) resourceLogger.LogInformation("Finished waiting for resource '{ResourceName}'.", displayName); + if (onDependencyReady is not null) + { + await onDependencyReady(resourceId).ConfigureAwait(false); + } + static bool IsKnownTerminalState(CustomResourceSnapshot snapshot) => KnownResourceStates.TerminalStates.Contains(snapshot.State?.Text) || snapshot.ExitCode is not null; @@ -360,22 +354,11 @@ static bool IsKnownTerminalState(CustomResourceSnapshot snapshot) => } private async Task WaitUntilStateAsync(IResource resource, IResource dependency, WaitBehavior waitBehavior, - Func postRunningAction, CancellationToken cancellationToken) + Func postRunningAction, CancellationToken cancellationToken, Func? onDependencyReady = null) { var resourceLogger = _resourceLoggerService.GetLogger(resource); resourceLogger.LogInformation("Waiting for resource '{ResourceName}' to enter the '{State}' state.", dependency.Name, KnownResourceStates.Running); - // Only transition replicas that are actually starting up to "Waiting". - // Replicas already in a Running or terminal state should not be clobbered, - // as this broadcast targets ALL replicas of the resource (model-level update), - // not just the specific replica being started. - await PublishUpdateAsync(resource, s => - s.State?.Text is null - || s.State?.Text == KnownResourceStates.Starting - || s.State?.Text == KnownResourceStates.Waiting - ? s with { State = KnownResourceStates.Waiting } - : s).ConfigureAwait(false); - var names = dependency.GetResolvedResourceNames(); var tasks = new Task[names.Length]; @@ -427,6 +410,11 @@ async Task Core(string displayName, string resourceId) // Execute the post-running action specific to the wait type await postRunningAction(resourceLogger, displayName, resourceId, resourceEvent).ConfigureAwait(false); + if (onDependencyReady is not null) + { + await onDependencyReady(resourceId).ConfigureAwait(false); + } + static bool IsContinuableState(WaitBehavior waitBehavior, CustomResourceSnapshot snapshot) => waitBehavior switch { @@ -441,7 +429,7 @@ static bool IsContinuableState(WaitBehavior waitBehavior, CustomResourceSnapshot } } - private async Task WaitUntilStartedAsync(IResource resource, IResource dependency, WaitBehavior waitBehavior, CancellationToken cancellationToken) + private async Task WaitUntilStartedAsync(IResource resource, IResource dependency, WaitBehavior waitBehavior, CancellationToken cancellationToken, Func? onDependencyReady = null) { using var activity = ProfilingTelemetry.StartResourceWaitForDependency(Configuration, resource, dependency, WaitType.WaitUntilStarted, waitBehavior); @@ -453,7 +441,7 @@ await WaitUntilStateAsync(resource, dependency, waitBehavior, (resourceLogger, d // We only wait for the resource to reach the Running state. resourceLogger.LogInformation("Finished waiting for resource '{ResourceName}' to start.", displayName); return Task.CompletedTask; - }, cancellationToken).ConfigureAwait(false); + }, cancellationToken, onDependencyReady).ConfigureAwait(false); } catch (Exception ex) { @@ -486,26 +474,102 @@ public async Task WaitForDependenciesAsync(IResource resource, CancellationToken try { - var pendingDependencies = new List(); - foreach (var waitAnnotation in waitAnnotationList) + var waitAnnotationsToProcess = waitAnnotationList + .Where(static waitAnnotation => waitAnnotation.Resource is not IResourceWithoutLifetime) + .ToArray(); + + if (waitAnnotationsToProcess.Length == 0) { - if (waitAnnotation.Resource is IResourceWithoutLifetime) + return; + } + + var pendingDependencyCounts = waitAnnotationsToProcess + .SelectMany(static waitAnnotation => waitAnnotation.Resource.GetResolvedResourceNames()) + .GroupBy(static dependencyName => dependencyName, StringComparers.ResourceName) + .ToDictionary(static group => group.Key, static group => group.Count(), StringComparers.ResourceName); + + if (pendingDependencyCounts.Count == 0) + { + return; + } + + await PublishWaitingForDependenciesAsync(resource, pendingDependencyCounts.Keys).ConfigureAwait(false); + + using var pendingDependencyLock = new SemaphoreSlim(1, 1); + + async Task OnDependencyReadyAsync(string dependencyName) + { + await pendingDependencyLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try { - // IResourceWithoutLifetime are inert and don't need to be waited on. - continue; + if (!pendingDependencyCounts.TryGetValue(dependencyName, out var pendingCount)) + { + return; + } + + if (pendingCount == 1) + { + pendingDependencyCounts.Remove(dependencyName); + } + else + { + pendingDependencyCounts[dependencyName] = pendingCount - 1; + } + + var waitingFor = pendingDependencyCounts.Keys.ToArray(); + + if (waitingFor.Length > 0) + { + await PublishWaitingForDependenciesAsync(resource, waitingFor).ConfigureAwait(false); + } + else + { + await ClearWaitingForDependenciesAsync(resource).ConfigureAwait(false); + } } + finally + { + pendingDependencyLock.Release(); + } + } - var pendingDependency = waitAnnotation.WaitType switch + var pendingDependencies = waitAnnotationsToProcess + .Select(waitAnnotation => waitAnnotation.WaitType switch { - WaitType.WaitUntilHealthy => WaitUntilHealthyAsync(resource, waitAnnotation.Resource, waitAnnotation.WaitBehavior ?? DefaultWaitBehavior, cancellationToken), - WaitType.WaitForCompletion => WaitUntilCompletionAsync(resource, waitAnnotation.Resource, waitAnnotation.ExitCode, cancellationToken), - WaitType.WaitUntilStarted => WaitUntilStartedAsync(resource, waitAnnotation.Resource, waitAnnotation.WaitBehavior ?? DefaultWaitBehavior, cancellationToken), + WaitType.WaitUntilHealthy => WaitUntilHealthyAsync(resource, waitAnnotation.Resource, waitAnnotation.WaitBehavior ?? DefaultWaitBehavior, cancellationToken, OnDependencyReadyAsync), + WaitType.WaitForCompletion => WaitUntilCompletionAsync(resource, waitAnnotation.Resource, waitAnnotation.ExitCode, cancellationToken, OnDependencyReadyAsync), + WaitType.WaitUntilStarted => WaitUntilStartedAsync(resource, waitAnnotation.Resource, waitAnnotation.WaitBehavior ?? DefaultWaitBehavior, cancellationToken, OnDependencyReadyAsync), _ => throw new DistributedApplicationException($"Unexpected wait type: {waitAnnotation.WaitType}") - }; - pendingDependencies.Add(pendingDependency); - } + }); await Task.WhenAll(pendingDependencies).ConfigureAwait(false); + + var clearRemainingDependencies = false; + await pendingDependencyLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + clearRemainingDependencies = pendingDependencyCounts.Count > 0; + pendingDependencyCounts.Clear(); + } + finally + { + pendingDependencyLock.Release(); + } + + if (clearRemainingDependencies) + { + await ClearWaitingForDependenciesAsync(resource).ConfigureAwait(false); + } + } + catch (OperationCanceledException ex) + { + activity.SetError(ex); + + var errorMessage = BuildCancellationErrorMessage( + $"Resource '{resource.Name}' failed to wait for dependencies before the operation was cancelled.", + resource.Name); + + throw new OperationCanceledException(errorMessage, ex, ex.CancellationToken); } catch (Exception ex) { @@ -514,6 +578,37 @@ public async Task WaitForDependenciesAsync(IResource resource, CancellationToken } } + private Task PublishWaitingForDependenciesAsync(IResource resource, IEnumerable dependencyNames) + { + var waitingFor = dependencyNames + .Where(static dependencyName => !string.IsNullOrWhiteSpace(dependencyName)) + .Distinct(StringComparers.ResourceName) + .ToArray(); + + // Only transition replicas that are actually starting up to "Waiting". + // Replicas already in a Running or terminal state should not be clobbered, + // as this broadcast targets ALL replicas of the resource (model-level update), + // not just the specific replica being started. + return PublishUpdateAsync(resource, s => + s.State?.Text is null + || s.State?.Text == KnownResourceStates.Starting + || s.State?.Text == KnownResourceStates.Waiting + ? s with + { + State = KnownResourceStates.Waiting, + Properties = s.Properties.SetResourceProperty(KnownProperties.Resource.WaitingFor, waitingFor) + } + : s); + } + + private Task ClearWaitingForDependenciesAsync(IResource resource) + { + return PublishUpdateAsync(resource, s => s with + { + Properties = s.Properties.RemoveResourceProperty(KnownProperties.Resource.WaitingFor) + }); + } + /// /// Waits until a resource satisfies the specified predicate. /// @@ -718,6 +813,14 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func properties, [NotNullWhen(true)] out string[]? dependencies) + { + foreach (var property in properties) + { + if (string.Equals(property.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName)) + { + if (property.Value is IEnumerable dependencyNames) + { + dependencies = dependencyNames + .Where(static dependencyName => !string.IsNullOrWhiteSpace(dependencyName)) + .Distinct(StringComparers.ResourceName) + .ToArray(); + + return dependencies.Length > 0; + } + + break; + } + } + + dependencies = null; + return false; + } + /// public void Dispose() { diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 3e108e2212a..e29bc45a215 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Channels; +using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Diagnostics; using Aspire.Shared; @@ -833,7 +834,8 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu // Build properties dictionary from ResourcePropertySnapshot // Redact sensitive property values to avoid leaking secrets - var properties = new Dictionary(); + var properties = new Dictionary(); + string[]? waitingFor = null; foreach (var prop in snapshot.Properties) { // Redact sensitive property values @@ -843,16 +845,12 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu continue; } - // Convert value to string representation - var stringValue = prop.Value switch + properties[prop.Name] = ConvertPropertyValueToJsonNode(prop.Value); + + if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName)) { - null => null, - string s => s, - IEnumerable enumerable => string.Join(", ", enumerable), - System.Collections.IEnumerable enumerable => string.Join(", ", enumerable.Cast()), - _ => prop.Value.ToString() - }; - properties[prop.Name] = stringValue; + waitingFor = GetStringArrayPropertyValue(prop.Value); + } } // Build commands @@ -888,6 +886,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu DisplayName = resource.Name, ResourceType = snapshot.ResourceType, State = snapshot.State?.Text, + WaitingFor = waitingFor, StateStyle = snapshot.State?.Style, IsHidden = snapshot.IsHidden, HealthStatus = snapshot.HealthStatus?.ToString(), @@ -906,6 +905,56 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu }; } + private static JsonNode? ConvertPropertyValueToJsonNode(object? value) + { + return value switch + { + null => null, + JsonNode jsonNode => jsonNode.DeepClone(), + string stringValue => JsonValue.Create(stringValue), + bool boolValue => JsonValue.Create(boolValue), + byte byteValue => JsonValue.Create(byteValue), + sbyte sbyteValue => JsonValue.Create(sbyteValue), + short shortValue => JsonValue.Create(shortValue), + ushort ushortValue => JsonValue.Create(ushortValue), + int intValue => JsonValue.Create(intValue), + uint uintValue => JsonValue.Create(uintValue), + long longValue => JsonValue.Create(longValue), + ulong ulongValue => JsonValue.Create(ulongValue), + float floatValue => JsonValue.Create(floatValue), + double doubleValue => JsonValue.Create(doubleValue), + decimal decimalValue => JsonValue.Create(decimalValue), + System.Collections.IEnumerable enumerable => ConvertEnumerablePropertyValueToJsonArray(enumerable), + _ => JsonValue.Create(value.ToString()) + }; + } + + private static JsonArray ConvertEnumerablePropertyValueToJsonArray(System.Collections.IEnumerable enumerable) + { + var array = new JsonArray(); + foreach (var value in enumerable) + { + array.Add(ConvertPropertyValueToJsonNode(value)); + } + + return array; + } + + private static string[]? GetStringArrayPropertyValue(object? value) + { + return value switch + { + null => null, + string s => [s], + IEnumerable strings => strings.Where(static s => !string.IsNullOrEmpty(s)).ToArray(), + IEnumerable objects => objects.OfType().Where(static s => !string.IsNullOrEmpty(s)).ToArray(), + System.Collections.IEnumerable enumerable => enumerable.Cast().OfType().Where(static s => !string.IsNullOrEmpty(s)).ToArray(), + _ => null + } is { Length: > 0 } values + ? values + : null; + } + private static Dictionary? CreateOptionsDictionary(IReadOnlyList>? options) { if (options is null) diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index d645cf5abfe..c4d64a1f1a3 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -992,6 +992,11 @@ public string? Type /// public string? State { get; init; } + /// + /// Gets the names of resources this resource is waiting for. + /// + public string[]? WaitingFor { get; init; } + /// /// Gets the state style hint (e.g., "success", "error", "warning"). /// @@ -1051,7 +1056,7 @@ public string? Type /// Gets additional properties as key-value pairs. /// This allows for extensibility without changing the schema. /// - public Dictionary Properties { get; init; } = []; + public Dictionary Properties { get; init; } = []; /// /// Gets a value indicating whether this resource is hidden. diff --git a/src/Shared/CustomResourceSnapshotExtensions.cs b/src/Shared/CustomResourceSnapshotExtensions.cs index 9a961a2690e..04b6980f0c6 100644 --- a/src/Shared/CustomResourceSnapshotExtensions.cs +++ b/src/Shared/CustomResourceSnapshotExtensions.cs @@ -63,4 +63,17 @@ internal static ImmutableArray SetResourcePropertyRang return [.. existingProperties, .. propertiesToAdd]; } + + internal static ImmutableArray RemoveResourceProperty(this ImmutableArray properties, string name) + { + for (var i = 0; i < properties.Length; i++) + { + if (string.Equals(properties[i].Name, name, StringComparisons.ResourcePropertyName)) + { + return properties.RemoveAt(i); + } + } + + return properties; + } } diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index db917b32720..3f86cdc5a63 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -30,6 +30,7 @@ public static class Resource public const string AppArgs = "resource.appArgs"; public const string AppArgsSensitivity = "resource.appArgsSensitivity"; public const string ExcludeFromMcp = "resource.excludeFromMcp"; + public const string WaitingFor = "resource.waitingFor"; } public static class Container diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index f25e6e51c82..5546385195f 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Serialization; +using System.Text.Json.Nodes; namespace Aspire.Shared.Model.Serialization; @@ -36,6 +37,11 @@ internal sealed class ResourceJson /// public string? State { get; set; } + /// + /// The display names of resources this resource is waiting for. + /// + public string[]? WaitingFor { get; set; } + /// /// The state style hint (e.g., "success", "error", "warning"). /// @@ -95,7 +101,7 @@ internal sealed class ResourceJson /// The properties of the resource. /// Dictionary key is the property name, value is the property value. /// - public Dictionary? Properties { get; set; } + public Dictionary? Properties { get; set; } /// /// The environment variables associated with the resource. diff --git a/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs b/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs index ffdf428a7ae..6fe46ed0144 100644 --- a/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs +++ b/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs @@ -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.Shared.Model.Serialization; #if !CLI @@ -30,6 +31,8 @@ namespace Aspire.Otlp.Serialization; [JsonSerializable(typeof(OtlpInstrumentationScopeJson))] [JsonSerializable(typeof(OtlpEntityRefJson))] [JsonSerializable(typeof(OtlpResourceJson))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(OtlpTelemetryDataJson))] // Trace types [JsonSerializable(typeof(OtlpResourceSpansJson))] diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 36488a404d8..fffbd05e1c5 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -1,7 +1,10 @@ // 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; +using System.Text.Json.Nodes; using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; namespace Aspire.Cli.Tests.Backchannel; @@ -123,6 +126,97 @@ public void MapToResourceJson_WithWhitespaceCommandDisplayName_MapsDisplayNameTo Assert.Equal("Run custom command", command.Value.Description); } + [Fact] + public void MapToResourceJson_ResolvesWaitingForDependencies() + { + var dependency = new ResourceSnapshot + { + Name = "messaging-abcxyz", + DisplayName = "messaging", + ResourceType = "Container", + State = "Running" + }; + + var resource = new ResourceSnapshot + { + Name = "frontend", + DisplayName = "frontend", + ResourceType = "Project", + State = "Waiting", + WaitingFor = ["messaging-abcxyz"] + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(resource, [resource, dependency]); + + Assert.NotNull(result.WaitingFor); + Assert.Equal(["messaging"], result.WaitingFor); + } + + [Fact] + public void MapToResourceJson_MapsListPropertiesAsJsonArrays() + { + var resource = new ResourceSnapshot + { + Name = "frontend", + DisplayName = "frontend", + ResourceType = "Project", + State = "Waiting", + Properties = new Dictionary + { + ["custom.list"] = new JsonArray((JsonNode?)JsonValue.Create("one"), (JsonNode?)JsonValue.Create("two")) + } + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(resource, [resource]); + + Assert.NotNull(result.Properties); + var listProperty = Assert.IsType(result.Properties["custom.list"]); + Assert.Collection( + listProperty, + value => Assert.Equal("one", value?.GetValue()), + value => Assert.Equal("two", value?.GetValue())); + + var json = JsonSerializer.Serialize(result, ResourcesCommandJsonContext.RelaxedEscaping.ResourceJson); + using var document = JsonDocument.Parse(json); + var serializedProperty = document.RootElement.GetProperty("properties").GetProperty("custom.list"); + Assert.Equal(JsonValueKind.Array, serializedProperty.ValueKind); + Assert.Equal("one", serializedProperty[0].GetString()); + Assert.Equal("two", serializedProperty[1].GetString()); + } + + [Fact] + public void MapToResourceJson_UsesUniqueWaitingForNamesForReplicas() + { + var firstDependency = new ResourceSnapshot + { + Name = "messaging-abcxyz", + DisplayName = "messaging", + ResourceType = "Container", + State = "Running" + }; + var secondDependency = new ResourceSnapshot + { + Name = "messaging-defuvw", + DisplayName = "messaging", + ResourceType = "Container", + State = "Running" + }; + + var resource = new ResourceSnapshot + { + Name = "frontend", + DisplayName = "frontend", + ResourceType = "Project", + State = "Waiting", + WaitingFor = ["messaging-abcxyz"] + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(resource, [resource, firstDependency, secondDependency]); + + Assert.NotNull(result.WaitingFor); + Assert.Equal(["messaging-abcxyz"], result.WaitingFor); + } + [Fact] public void ResolveResources_ByExactName_ReturnsMatch() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index a15f09f2583..8976b5e3de6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -3,10 +3,11 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Globalization; using Aspire.Dashboard.Components.Controls; -using Aspire.Dashboard.Resources; using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Resources; using Aspire.Dashboard.Tests.Shared; using Aspire.Tests.Shared.DashboardModel; using Bunit; @@ -374,26 +375,81 @@ public async Task ClickMaskEnvVarSwitch_NewResource_MaskChanged() [Fact] public void Render_StateDescription_ShowsAsResourceDetailEntry() { - // Arrange ResourceSetupHelpers.SetupResourceDetails(this); var resource = ModelTestHelpers.CreateResource( resourceName: "app1", state: KnownResourceState.Waiting); - // Act var cut = RenderComponent(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); }); - // Assert var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); Assert.Contains(Columns.StateColumnResourceWaiting, resourcePropertyGrid.TextContent); } + [Fact] + public void Render_NotStartedStateDescription_ShowsDescription() + { + ResourceSetupHelpers.SetupResourceDetails(this); + + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1", + state: KnownResourceState.NotStarted); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.Resource, resource); + builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); + }); + + var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; + Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); + Assert.Contains(Columns.StateColumnResourceNotStarted, resourcePropertyGrid.TextContent); + } + + [Fact] + public void Render_StateDescription_ShowsWaitingForDependenciesAsResourceDetailEntry() + { + ResourceSetupHelpers.SetupResourceDetails(this); + + var nginx = ModelTestHelpers.CreateResource(resourceName: "nginx-abcxyz", displayName: "nginx"); + var redis = ModelTestHelpers.CreateResource(resourceName: "redis"); + + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1", + state: KnownResourceState.Waiting, + properties: new Dictionary + { + [KnownProperties.Resource.WaitingFor] = new( + KnownProperties.Resource.WaitingFor, + Value.ForList(Value.ForString("nginx-abcxyz"), Value.ForString("redis")), + isValueSensitive: false, + knownProperty: null, + priority: 0) + }); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.Resource, resource); + builder.Add(p => p.ResourceByName, new ConcurrentDictionary([ + new KeyValuePair(resource.Name, resource), + new KeyValuePair(nginx.Name, nginx), + new KeyValuePair(redis.Name, redis) + ])); + }); + + var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; + Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); + Assert.Contains(string.Format(CultureInfo.InvariantCulture, Columns.StateColumnResourceWaitingFor, "nginx, redis"), resourcePropertyGrid.TextContent); + + Assert.Empty(resourcePropertyGrid.QuerySelectorAll("fluent-anchor")); + } + [Fact] public void Render_NullState_ShowsUnknownStateInResourceDetails() { diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index 62d1ce01296..a29d60230d4 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -13,6 +13,7 @@ using Google.Protobuf.Collections; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.FluentUI.AspNetCore.Components; using OpenTelemetry.Proto.Trace.V1; using Xunit; @@ -250,6 +251,53 @@ public void AddMenuItems_WithoutFromSpecEnvVars_ExportEnvMenuItemNotShown() e => Assert.Equal("Localized:ExportJson", e.Text)); } + [Fact] + public void AddMenuItems_ShowStartCommandFalse_FiltersStartCommand() + { + var startCommand = new CommandViewModel( + CommandViewModel.StartCommand, + CommandViewModelState.Enabled, + "Start", + "Start the resource.", + confirmationMessage: "", + argumentInputs: [], + isHighlighted: true, + iconName: string.Empty, + iconVariant: IconVariant.Regular); + var stopCommand = new CommandViewModel( + CommandViewModel.StopCommand, + CommandViewModelState.Enabled, + "Stop", + "Stop the resource.", + confirmationMessage: "", + argumentInputs: [], + isHighlighted: true, + iconName: string.Empty, + iconVariant: IconVariant.Regular); + var resource = ModelTestHelpers.CreateResource(commands: [startCommand, stopCommand]); + var repository = TelemetryTestHelpers.CreateRepository(); + var aiContextProvider = new TestAIContextProvider(); + var resourceMenuBuilder = CreateResourceMenuBuilder(repository, aiContextProvider); + + var menuItems = new List(); + resourceMenuBuilder.AddMenuItems( + menuItems, + resource, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, + EventCallback.Empty, + EventCallback.Empty, + (_, _) => false, + showViewDetails: false, + showConsoleLogsItem: false, + showUrls: false, + showStartCommand: false); + + Assert.Collection(menuItems, + e => Assert.Equal("Localized:ExportJson", e.Text), + e => Assert.True(e.IsDivider), + e => Assert.Equal("Stop", e.Text)); + } + private sealed class TestNavigationManager : NavigationManager { } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs index 956deb3c4ac..6b78947b0f3 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs @@ -108,4 +108,81 @@ public void ResourceViewModel_ReturnsCorrectIconAndTooltip( Assert.Equal(expectedColor, vm.Color); Assert.Equal(expectedText, vm.Text); } + + [Fact] + public void WaitingResourceTooltipIncludesWaitingForDependenciesWhenPresent() + { + var resource = ModelTestHelpers.CreateResource( + state: KnownResourceState.Waiting, + properties: new Dictionary + { + [KnownProperties.Resource.WaitingFor] = new( + KnownProperties.Resource.WaitingFor, + Value.ForList(Value.ForString("nginx"), Value.ForString("redis")), + isValueSensitive: false, + knownProperty: null, + priority: 0) + }); + + var localizer = new TestStringLocalizer(); + + var tooltip = ResourceStateViewModel.GetResourceStateTooltip(resource, localizer); + + Assert.Equal($"Localized:{nameof(Columns.StateColumnResourceWaitingFor)}:nginx, redis", tooltip); + } + + [Fact] + public void WaitingResourceTooltipUsesDisplayNamesForNonReplicaDependencies() + { + var dependency = ModelTestHelpers.CreateResource( + resourceName: "messaging-abcxyz", + displayName: "messaging"); + + var resource = ModelTestHelpers.CreateResource( + state: KnownResourceState.Waiting, + properties: new Dictionary + { + [KnownProperties.Resource.WaitingFor] = new( + KnownProperties.Resource.WaitingFor, + Value.ForList(Value.ForString("messaging-abcxyz")), + isValueSensitive: false, + knownProperty: null, + priority: 0) + }); + + var localizer = new TestStringLocalizer(); + + var tooltip = ResourceStateViewModel.GetResourceStateTooltip(resource, localizer, [resource, dependency]); + + Assert.Equal($"Localized:{nameof(Columns.StateColumnResourceWaitingFor)}:messaging", tooltip); + } + + [Fact] + public void WaitingResourceTooltipUsesUniqueNamesForReplicaDependencies() + { + var firstDependency = ModelTestHelpers.CreateResource( + resourceName: "messaging-abcxyz", + displayName: "messaging"); + var secondDependency = ModelTestHelpers.CreateResource( + resourceName: "messaging-defuvw", + displayName: "messaging"); + + var resource = ModelTestHelpers.CreateResource( + state: KnownResourceState.Waiting, + properties: new Dictionary + { + [KnownProperties.Resource.WaitingFor] = new( + KnownProperties.Resource.WaitingFor, + Value.ForList(Value.ForString("messaging-abcxyz")), + isValueSensitive: false, + knownProperty: null, + priority: 0) + }); + + var localizer = new TestStringLocalizer(); + + var tooltip = ResourceStateViewModel.GetResourceStateTooltip(resource, localizer, [resource, firstDependency, secondDependency]); + + Assert.Equal($"Localized:{nameof(Columns.StateColumnResourceWaitingFor)}:messaging-abcxyz", tooltip); + } } diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 751340f8d89..c68eeedbc50 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO.Compression; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Serialization; using Aspire.Dashboard.Otlp.Model; @@ -13,6 +14,7 @@ using Aspire.Dashboard.Tests.TelemetryRepositoryTests; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.InternalTesting; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; @@ -1184,6 +1186,15 @@ public void ConvertResourceToJson_ReturnsExpectedJson() state: KnownResourceState.Running, urls: [new UrlViewModel("http", new Uri("http://localhost:5000"), isInternal: false, isInactive: false, UrlDisplayPropertiesViewModel.Empty)], environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: true)], + properties: new Dictionary + { + [KnownProperties.Resource.WaitingFor] = new( + KnownProperties.Resource.WaitingFor, + Value.ForList(Value.ForString("dependency-resource")), + isValueSensitive: false, + knownProperty: null, + priority: 0) + }, relationships: [new RelationshipViewModel("dependency", "Reference")]); var allResources = new[] { resource, dependencyResource }; @@ -1198,6 +1209,12 @@ public void ConvertResourceToJson_ReturnsExpectedJson() Assert.Equal("Test Resource", deserialized.DisplayName); Assert.Equal("Container", deserialized.ResourceType); Assert.Equal("Running", deserialized.State); + Assert.NotNull(deserialized.WaitingFor); + Assert.Equal(["dependency"], deserialized.WaitingFor); + Assert.NotNull(deserialized.Properties); + var waitingForProperty = Assert.IsType(deserialized.Properties[KnownProperties.Resource.WaitingFor]); + var waitingForPropertyValue = Assert.Single(waitingForProperty); + Assert.Equal("dependency-resource", waitingForPropertyValue?.GetValue()); Assert.NotNull(deserialized.Urls); Assert.Single(deserialized.Urls); diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index 725f10a00d3..adc7c41f614 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Hosting.Diagnostics; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -254,7 +255,8 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with // Properties (sensitive values should be redacted) Assert.True(snapshot.Properties.TryGetValue(CustomResourceKnownProperties.Source, out var normalValue)); - Assert.Equal("normal-value", normalValue); + var normalJsonValue = Assert.IsAssignableFrom(normalValue); + Assert.Equal("normal-value", normalJsonValue.GetValue()); Assert.True(snapshot.Properties.TryGetValue("ConnectionString", out var sensitiveValue)); Assert.Null(sensitiveValue); diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index d49da1a4b85..9d23e9a8570 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -348,6 +348,297 @@ await Assert.ThrowsAsync(async () => }).DefaultTimeout(); } + [Fact] + public async Task WaitForDependenciesPublishesAndUpdatesWaitingForDependencies() + { + var dependency1 = new CustomResource("dependency1"); + var dependency2 = new CustomResource("dependency2"); + var resource = new CustomResource("resource"); + resource.Annotations.Add(new WaitAnnotation(dependency1, WaitType.WaitUntilStarted)); + resource.Annotations.Add(new WaitAnnotation(dependency2, WaitType.WaitUntilStarted)); + + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token); + + var waitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency1.Name, dependency2.Name }), + cts.Token).DefaultTimeout(); + + Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text); + + await notificationService.PublishUpdateAsync(dependency1, s => s with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + var updatedWaitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency2.Name }), + cts.Token).DefaultTimeout(); + + Assert.Equal(new[] { dependency2.Name }, GetWaitingForDependencies(updatedWaitingEvent)); + + await notificationService.PublishUpdateAsync(dependency2, s => s with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + await waitTask.DefaultTimeout(); + + Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent)); + Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor); + } + + [Fact] + public async Task WaitForDependenciesPublishesAndUpdatesWaitingForHealthyDependencies() + { + var dependency1 = new CustomResource("dependency1"); + dependency1.Annotations.Add(new HealthCheckAnnotation("dependency1-health")); + var dependency2 = new CustomResource("dependency2"); + dependency2.Annotations.Add(new HealthCheckAnnotation("dependency2-health")); + var resource = new CustomResource("resource"); + resource.Annotations.Add(new WaitAnnotation(dependency1, WaitType.WaitUntilHealthy)); + resource.Annotations.Add(new WaitAnnotation(dependency2, WaitType.WaitUntilHealthy)); + + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token); + + var waitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency1.Name, dependency2.Name }), + cts.Token).DefaultTimeout(); + + Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text); + + await notificationService.PublishUpdateAsync(dependency1, s => + (s with + { + State = KnownResourceStates.Running, + ResourceReadyEvent = new EventSnapshot(Task.CompletedTask) + }).WithHealthReports( + [ + new HealthReportSnapshot("dependency1-health", HealthStatus.Healthy, "Dependency is healthy.", null) + ])).DefaultTimeout(); + + var updatedWaitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency2.Name }), + cts.Token).DefaultTimeout(); + + Assert.Equal(new[] { dependency2.Name }, GetWaitingForDependencies(updatedWaitingEvent)); + + await notificationService.PublishUpdateAsync(dependency2, s => + (s with + { + State = KnownResourceStates.Running, + ResourceReadyEvent = new EventSnapshot(Task.CompletedTask) + }).WithHealthReports( + [ + new HealthReportSnapshot("dependency2-health", HealthStatus.Healthy, "Dependency is healthy.", null) + ])).DefaultTimeout(); + + await waitTask.DefaultTimeout(); + + Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent)); + Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor); + } + + [Fact] + public async Task WaitForDependenciesPublishesAndUpdatesWaitingForCompletionDependencies() + { + var dependency1 = new CustomResource("dependency1"); + var dependency2 = new CustomResource("dependency2"); + var resource = new CustomResource("resource"); + resource.Annotations.Add(new WaitAnnotation(dependency1, WaitType.WaitForCompletion)); + resource.Annotations.Add(new WaitAnnotation(dependency2, WaitType.WaitForCompletion)); + + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token); + + var waitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency1.Name, dependency2.Name }), + cts.Token).DefaultTimeout(); + + Assert.Equal(KnownResourceStates.Waiting, waitingEvent.Snapshot.State?.Text); + + await notificationService.PublishUpdateAsync(dependency1, s => s with + { + State = KnownResourceStates.Finished, + ExitCode = 0 + }).DefaultTimeout(); + + var updatedWaitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency2.Name }), + cts.Token).DefaultTimeout(); + + Assert.Equal(new[] { dependency2.Name }, GetWaitingForDependencies(updatedWaitingEvent)); + + await notificationService.PublishUpdateAsync(dependency2, s => s with + { + State = KnownResourceStates.Finished, + ExitCode = 0 + }).DefaultTimeout(); + + await waitTask.DefaultTimeout(); + + Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent)); + Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor); + } + + [Fact] + public async Task WaitForDependenciesPublishesResolvedWaitingForDependenciesForReplicas() + { + var dependency = new CustomResource("dependency"); + dependency.Annotations.Add(new DcpInstancesAnnotation([ + new DcpInstance("dependency-abc123", "abc123", 0), + new DcpInstance("dependency-def456", "def456", 1) + ])); + + var resource = new CustomResource("resource"); + resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted)); + + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token); + + var waitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { "dependency-abc123", "dependency-def456" }), + cts.Token).DefaultTimeout(); + + Assert.Equal(new[] { "dependency-abc123", "dependency-def456" }, GetWaitingForDependencies(waitingEvent)); + + await notificationService.PublishUpdateAsync(dependency, "dependency-abc123", s => s with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + var partialWaitingEvent = await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { "dependency-def456" }), + cts.Token).DefaultTimeout(); + + Assert.Equal(new[] { "dependency-def456" }, GetWaitingForDependencies(partialWaitingEvent)); + + await notificationService.PublishUpdateAsync(dependency, "dependency-def456", s => s with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + await waitTask.DefaultTimeout(); + + Assert.True(notificationService.TryGetCurrentState(resource.Name, out var completedWaitingEvent)); + Assert.DoesNotContain(completedWaitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor); + } + + [Fact] + public async Task PublishUpdateClearsWaitingForDependenciesWhenResourceLeavesWaiting() + { + var resource = new CustomResource("resource"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + await notificationService.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Waiting, + Properties = [new ResourcePropertySnapshot(KnownProperties.Resource.WaitingFor, new[] { "dependency" })] + }).DefaultTimeout(); + + Assert.True(notificationService.TryGetCurrentState(resource.Name, out var waitingEvent)); + Assert.Contains(waitingEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor); + + await notificationService.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + Assert.True(notificationService.TryGetCurrentState(resource.Name, out var runningEvent)); + Assert.DoesNotContain(runningEvent.Snapshot.Properties, p => p.Name == KnownProperties.Resource.WaitingFor); + } + + [Fact] + public async Task CancellationMessageIncludesWaitingForDependencies() + { + var resource = new CustomResource("resource"); + var dependency = new CustomResource("dependency"); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + await notificationService.PublishUpdateAsync(dependency, s => s with + { + State = KnownResourceStates.Running + }).DefaultTimeout(); + + await notificationService.PublishUpdateAsync(resource, s => s with + { + State = KnownResourceStates.Waiting, + Properties = [new ResourcePropertySnapshot(KnownProperties.Resource.WaitingFor, new[] { dependency.Name })] + }).DefaultTimeout(); + + using var cts = new CancellationTokenSource(); + var waitTask = notificationService.WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token); + await cts.CancelAsync(); + + var ex = await Assert.ThrowsAsync(async () => + { + await waitTask; + }).DefaultTimeout(); + + Assert.Contains("- Waiting For:", ex.Message); + Assert.Contains(" - dependency: State = Running, Health = Healthy", ex.Message); + } + + [Fact] + public async Task WaitForDependenciesCancellationMessageIncludesWaitingForDependencies() + { + var resource = new CustomResource("resource"); + var dependency = new CustomResource("dependency"); + resource.Annotations.Add(new WaitAnnotation(dependency, WaitType.WaitUntilStarted)); + + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + + await notificationService.PublishUpdateAsync(dependency, s => s with + { + State = KnownResourceStates.Starting + }).DefaultTimeout(); + + using var cts = new CancellationTokenSource(); + var waitTask = notificationService.WaitForDependenciesAsync(resource, cts.Token); + + await notificationService.WaitForResourceAsync( + resource.Name, + re => re.Snapshot.State?.Text == KnownResourceStates.Waiting && + GetWaitingForDependencies(re).SequenceEqual(new[] { dependency.Name }), + TestContext.Current.CancellationToken).DefaultTimeout(); + + await cts.CancelAsync(); + + var ex = await Assert.ThrowsAsync(async () => + { + await waitTask; + }).DefaultTimeout(); + + Assert.Contains("Resource 'resource' failed to wait for dependencies before the operation was cancelled.", ex.Message); + Assert.Contains("- Waiting For:", ex.Message); + Assert.Contains(" - dependency: State = Starting", ex.Message); + } + [Fact] public async Task WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeServiceIsDisposed() { @@ -778,4 +1069,10 @@ public void Dispose() _stoppingCts.Dispose(); } } + + private static string[] GetWaitingForDependencies(ResourceEvent resourceEvent) + { + var property = resourceEvent.Snapshot.Properties.SingleOrDefault(p => p.Name == KnownProperties.Resource.WaitingFor); + return property?.Value is IEnumerable dependencyNames ? dependencyNames.ToArray() : []; + } }