From 3346ce8cfbf160afa6cb2ff4f689df1fdc6f334e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 14:18:38 -0400 Subject: [PATCH 01/20] Show waiting dependency details in dashboard --- .../ResourceStateDescriptionValue.razor | 32 +++ .../ResourceStateDescriptionValue.razor.cs | 103 ++++++++ .../Controls/ResourceDetails.razor.cs | 10 + .../Model/ResourceStateViewModel.cs | 5 + .../Model/ResourceViewModelExtensions.cs | 5 + .../Resources/Columns.Designer.cs | 9 + src/Aspire.Dashboard/Resources/Columns.resx | 4 + .../Resources/xlf/Columns.cs.xlf | 5 + .../Resources/xlf/Columns.de.xlf | 5 + .../Resources/xlf/Columns.es.xlf | 5 + .../Resources/xlf/Columns.fr.xlf | 5 + .../Resources/xlf/Columns.it.xlf | 5 + .../Resources/xlf/Columns.ja.xlf | 5 + .../Resources/xlf/Columns.ko.xlf | 5 + .../Resources/xlf/Columns.pl.xlf | 5 + .../Resources/xlf/Columns.pt-BR.xlf | 5 + .../Resources/xlf/Columns.ru.xlf | 5 + .../Resources/xlf/Columns.tr.xlf | 5 + .../Resources/xlf/Columns.zh-Hans.xlf | 5 + .../Resources/xlf/Columns.zh-Hant.xlf | 5 + .../ResourceNotificationService.cs | 219 ++++++++++++++---- .../CustomResourceSnapshotExtensions.cs | 13 ++ src/Shared/Model/KnownProperties.cs | 1 + .../Controls/ResourceDetailsTests.cs | 56 ++++- .../Model/ResourceStateViewModelTests.cs | 22 ++ .../ResourceNotificationTests.cs | 156 +++++++++++++ 26 files changed, 656 insertions(+), 44 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor new file mode 100644 index 00000000000..32ef090357a --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor @@ -0,0 +1,32 @@ +@using Aspire.Dashboard.Utils + +@if (_waitingResources.Count == 0) +{ + +} + +else +{ + + @for (var index = 0; index < _waitingResources.Count; index++) + { + var waitingResource = _waitingResources[index]; + + if (index > 0) + { + , + } + + if (waitingResource.Resource is { } resource) + { + + + + } + else + { + + } + } + +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs new file mode 100644 index 00000000000..dd1a521f82e --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Resources; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +namespace Aspire.Dashboard.Components.Controls.PropertyValues; + +public partial class ResourceStateDescriptionValue +{ + private const string WaitingResourcePlaceholder = "{0}"; + private string _prefix = string.Empty; + private string _suffix = string.Empty; + private List _waitingResources = []; + + [Parameter, EditorRequired] + public required string Value { get; set; } + + [Parameter, EditorRequired] + public required string HighlightText { get; set; } + + [Parameter, EditorRequired] + public required ResourceViewModel Resource { get; set; } + + [Parameter, EditorRequired] + public required IDictionary ResourceByName { get; set; } + + [Parameter] + public bool ShowHiddenResources { get; set; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + protected override void OnParametersSet() + { + _waitingResources = []; + _prefix = string.Empty; + _suffix = string.Empty; + + if (!Resource.TryGetWaitingForDependencies(out var dependencies)) + { + return; + } + + var waitingResourceNames = string.Join(", ", dependencies); + if (!TrySplitWaitingForFormat(waitingResourceNames, out _prefix, out _suffix)) + { + return; + } + + foreach (var dependency in dependencies) + { + if (TryGetVisibleResource(dependency, out var resource)) + { + _waitingResources.Add(new WaitingResource(resource, ResourceViewModel.GetResourceName(resource, ResourceByName))); + } + else + { + _waitingResources.Add(new WaitingResource(null, dependency)); + } + } + } + + private bool TrySplitWaitingForFormat(string waitingResourceNames, out string prefix, out string suffix) + { + var format = Loc[nameof(Columns.StateColumnResourceWaitingFor)].Value; + var placeholderIndex = format.IndexOf(WaitingResourcePlaceholder, StringComparison.Ordinal); + + if (placeholderIndex >= 0) + { + prefix = format[..placeholderIndex]; + suffix = format[(placeholderIndex + WaitingResourcePlaceholder.Length)..]; + return true; + } + + var resourceNamesIndex = Value.IndexOf(waitingResourceNames, StringComparison.Ordinal); + if (resourceNamesIndex >= 0) + { + prefix = Value[..resourceNamesIndex]; + suffix = Value[(resourceNamesIndex + waitingResourceNames.Length)..]; + return true; + } + + prefix = string.Empty; + suffix = string.Empty; + return false; + } + + private bool TryGetVisibleResource(string resourceName, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ResourceViewModel? resource) + { + if (ResourceViewModel.TryGetResourceByName(resourceName, ResourceByName, out resource) && !resource.IsResourceHidden(ShowHiddenResources)) + { + return true; + } + + resource = null; + return false; + } + + private sealed record WaitingResource(ResourceViewModel? Resource, string DisplayName); +} diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index 6bd77acb47e..b4b5d0713b4 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -213,6 +213,16 @@ protected override void OnParametersSet() Type = typeof(ResourceHealthStateValue), Parameters = { ["Resource"] = _resource } }, + [StateDescriptionPropertyKey] = new ComponentMetadata + { + Type = typeof(ResourceStateDescriptionValue), + Parameters = + { + ["Resource"] = _resource, + ["ResourceByName"] = ResourceByName, + ["ShowHiddenResources"] = ShowHiddenResources + } + } }; // For parameter resources whose value is unset, render the same "Value not set" affordance diff --git a/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs index 29e890428eb..4b4c7099b6b 100644 --- a/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs @@ -132,6 +132,11 @@ internal static string GetResourceStateTooltip(ResourceViewModel resource, IStri } else if (resource.IsWaiting()) { + if (resource.TryGetWaitingForDependencies(out var 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..ca82f223bb6 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs @@ -99,6 +99,11 @@ 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; + } + 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/Resources/Columns.Designer.cs b/src/Aspire.Dashboard/Resources/Columns.Designer.cs index 6e2d1f34ebf..f70a331535b 100644 --- a/src/Aspire.Dashboard/Resources/Columns.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Columns.Designer.cs @@ -152,6 +152,15 @@ public static string StateColumnResourceWaiting { 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..a812a01281b 100644 --- a/src/Aspire.Dashboard/Resources/Columns.resx +++ b/src/Aspire.Dashboard/Resources/Columns.resx @@ -164,4 +164,8 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy Resource is waiting for other resources to be in a running and healthy state. + + 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/xlf/Columns.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf index ad8a5494870..0e585a19ac4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf @@ -49,6 +49,11 @@ Další informace najdete na https://aka.ms/dotnet/aspire/container-runtime-unhe 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..85722a01e05 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf @@ -49,6 +49,11 @@ Weitere Informationen finden Sie unter https://aka.ms/dotnet/aspire/container-ru 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..e9b519119ea 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf @@ -49,6 +49,11 @@ Para obtener más información, consulte https://aka.ms/dotnet/aspire/container- 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..9b977988d20 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf @@ -49,6 +49,11 @@ Pour plus d’informations, consultez https://aka.ms/dotnet/aspire/container-run 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..8f4e3f95f30 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf @@ -49,6 +49,11 @@ Per altre informazioni, vedere https://aka.ms/dotnet/aspire/container-runtime-un 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..e7def0c230a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf @@ -49,6 +49,11 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthyリソースは、他のリソースが実行中で正常な状態になるのを待機しています。 + + 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..14b5c563cee 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf @@ -49,6 +49,11 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy해당 리소스는 다른 리소스가 실행 중이며 정상 상태가 될 때까지 대기하고 있습니다. + + 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..9f810010d2e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf @@ -49,6 +49,11 @@ Aby uzyskać więcej informacji, zobacz https://aka.ms/dotnet/aspire/container-r 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..55ef73f387a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf @@ -49,6 +49,11 @@ Para obter mais informações, consulte https://aka.ms/dotnet/aspire/container- 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..dc72b335c0b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf @@ -49,6 +49,11 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthyРесурс ожидает приведения других ресурсов в работоспособное состояние. + + 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..2a24d74c19a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf @@ -49,6 +49,11 @@ Daha fazla bilgi için bkz. https://aka.ms/dotnet/aspire/container-runtime-unhea 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..17510f64d4b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf @@ -49,6 +49,11 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy资源正在等待其他资源处于运行正常状态。 + + 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..5c4070da0ef 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf @@ -49,6 +49,11 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy資源正在等待其他資源處於執行中且狀態良好。 + + 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..20656718f00 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,92 @@ 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) + { + 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) { - if (waitAnnotation.Resource is IResourceWithoutLifetime) + 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 (Exception ex) { @@ -514,6 +568,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 +803,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/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/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index a15f09f2583..58e79da3ecc 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -4,10 +4,11 @@ using System.Collections.Concurrent; using System.Collections.Immutable; 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.Dashboard.Utils; using Aspire.Tests.Shared.DashboardModel; using Bunit; using Google.Protobuf.WellKnownTypes; @@ -374,26 +375,73 @@ 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_StateDescription_ShowsWaitingForDependenciesAsResourceDetailEntry() + { + ResourceSetupHelpers.SetupResourceDetails(this); + + var nginx = ModelTestHelpers.CreateResource(resourceName: "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"), 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("Waiting for dependencies: nginx, redis.", resourcePropertyGrid.TextContent); + + var links = resourcePropertyGrid.QuerySelectorAll("fluent-anchor"); + Assert.Collection( + links, + link => + { + Assert.Equal(DashboardUrls.ResourcesUrl(resource: nginx.Name), link.GetAttribute("href")); + Assert.Equal("hypertext", link.GetAttribute("appearance")); + }, + link => + { + Assert.Equal(DashboardUrls.ResourcesUrl(resource: redis.Name), link.GetAttribute("href")); + Assert.Equal("hypertext", link.GetAttribute("appearance")); + }); + } + [Fact] public void Render_NullState_ShowsUnknownStateInResourceDetails() { diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs index 956deb3c4ac..2e6ae608273 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs @@ -108,4 +108,26 @@ 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); + } } diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index d49da1a4b85..2f2dcf7d6fe 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -348,6 +348,156 @@ 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 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 WaitingOnResourceThrowsOperationCanceledExceptionIfResourceDoesntReachStateBeforeServiceIsDisposed() { @@ -778,4 +928,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() : []; + } } From 3ac6f775b3f13f30767606c989a7b8fd26ee87e4 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 14:33:38 -0400 Subject: [PATCH 02/20] Add state description actions --- .../ResourceStateDescriptionValue.razor | 13 +++++- .../ResourceStateDescriptionValue.razor.cs | 37 +++++++++++++++ .../ResourceStateDescriptionValue.razor.css | 20 +++++++++ .../Controls/ResourceDetails.razor.cs | 8 ++-- .../Resources/Columns.Designer.cs | 4 +- src/Aspire.Dashboard/Resources/Columns.resx | 4 +- .../Resources/xlf/Columns.cs.xlf | 8 ++-- .../Resources/xlf/Columns.de.xlf | 8 ++-- .../Resources/xlf/Columns.es.xlf | 8 ++-- .../Resources/xlf/Columns.fr.xlf | 8 ++-- .../Resources/xlf/Columns.it.xlf | 8 ++-- .../Resources/xlf/Columns.ja.xlf | 8 ++-- .../Resources/xlf/Columns.ko.xlf | 8 ++-- .../Resources/xlf/Columns.pl.xlf | 8 ++-- .../Resources/xlf/Columns.pt-BR.xlf | 8 ++-- .../Resources/xlf/Columns.ru.xlf | 8 ++-- .../Resources/xlf/Columns.tr.xlf | 8 ++-- .../Resources/xlf/Columns.zh-Hans.xlf | 8 ++-- .../Resources/xlf/Columns.zh-Hant.xlf | 8 ++-- .../Controls/ResourceDetailsTests.cs | 45 ++++++++++++++++++- 20 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor index 32ef090357a..2029a82d99f 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor @@ -4,7 +4,6 @@ { } - else { @@ -29,4 +28,16 @@ else } } +} + +@if (StartCommand is not null) +{ + + } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs index dd1a521f82e..57704df9771 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs @@ -14,6 +14,10 @@ public partial class ResourceStateDescriptionValue private string _prefix = string.Empty; private string _suffix = string.Empty; private List _waitingResources = []; + private CommandViewModel? StartCommand { get; set; } + private bool IsStartCommandDisabled => StartCommand is null || StartCommand.State == CommandViewModelState.Disabled || OnExecuteCommandAsync is null || IsStartCommandExecuting; + private bool IsStartCommandExecuting => StartCommand is not null && (IsCommandExecuting?.Invoke(Resource, StartCommand) ?? false); + private string StartCommandTitle => StartCommand?.GetDisplayDescription() ?? StartCommand?.GetDisplayName() ?? string.Empty; [Parameter, EditorRequired] public required string Value { get; set; } @@ -30,6 +34,12 @@ public partial class ResourceStateDescriptionValue [Parameter] public bool ShowHiddenResources { get; set; } + [Parameter] + public Func? OnExecuteCommandAsync { get; set; } + + [Parameter] + public Func? IsCommandExecuting { get; set; } + [Inject] public required IStringLocalizer Loc { get; init; } @@ -38,6 +48,7 @@ protected override void OnParametersSet() _waitingResources = []; _prefix = string.Empty; _suffix = string.Empty; + StartCommand = GetVisibleStartCommand(); if (!Resource.TryGetWaitingForDependencies(out var dependencies)) { @@ -99,5 +110,31 @@ private bool TryGetVisibleResource(string resourceName, [System.Diagnostics.Code return false; } + private CommandViewModel? GetVisibleStartCommand() + { + foreach (var command in Resource.Commands) + { + if (string.Equals(command.Name, CommandViewModel.StartCommand, StringComparisons.CommandName) && + command.State != CommandViewModelState.Hidden) + { + return command; + } + } + + return null; + } + + private Task OnStartCommandAsync() + { + if (StartCommand is not { } startCommand || + IsStartCommandDisabled || + OnExecuteCommandAsync is not { } onExecuteCommandAsync) + { + return Task.CompletedTask; + } + + return onExecuteCommandAsync(Resource, startCommand); + } + private sealed record WaitingResource(ResourceViewModel? Resource, string DisplayName); } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css new file mode 100644 index 00000000000..f8f7061cdf5 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css @@ -0,0 +1,20 @@ +.state-description-action { + display: inline; + padding: 0; + border: 0; + background: none; + color: var(--accent-fill-rest); + text-decoration: underline; + cursor: pointer; + font: inherit; +} + +.state-description-action:hover:not(:disabled) { + color: var(--accent-fill-hover); +} + +.state-description-action:disabled { + cursor: default; + opacity: var(--disabled-opacity); + text-decoration: none; +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index b4b5d0713b4..670e7bace12 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -220,7 +220,9 @@ protected override void OnParametersSet() { ["Resource"] = _resource, ["ResourceByName"] = ResourceByName, - ["ShowHiddenResources"] = ShowHiddenResources + ["ShowHiddenResources"] = ShowHiddenResources, + ["OnExecuteCommandAsync"] = (Func)ExecuteResourceCommandAsync, + ["IsCommandExecuting"] = IsCommandExecuting, } } }; @@ -235,7 +237,7 @@ protected override void OnParametersSet() Parameters = { ["Resource"] = _resource, - ["OnExecuteCommandAsync"] = (Func)ExecuteParameterCommandAsync, + ["OnExecuteCommandAsync"] = (Func)ExecuteResourceCommandAsync, ["IsCommandExecuting"] = IsCommandExecuting, } }; @@ -320,7 +322,7 @@ private void UpdateResourceActionsMenu() showUrls: true); } - private async Task ExecuteParameterCommandAsync(ResourceViewModel resource, CommandViewModel command) + private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command) { await CommandSelected.InvokeAsync(command); } diff --git a/src/Aspire.Dashboard/Resources/Columns.Designer.cs b/src/Aspire.Dashboard/Resources/Columns.Designer.cs index f70a331535b..191cb17347b 100644 --- a/src/Aspire.Dashboard/Resources/Columns.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Columns.Designer.cs @@ -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,7 +145,7 @@ 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 { diff --git a/src/Aspire.Dashboard/Resources/Columns.resx b/src/Aspire.Dashboard/Resources/Columns.resx index a812a01281b..df82f7caee4 100644 --- a/src/Aspire.Dashboard/Resources/Columns.resx +++ b/src/Aspire.Dashboard/Resources/Columns.resx @@ -159,10 +159,10 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthyContains 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}. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf index 0e585a19ac4..e894764e736 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf @@ -40,13 +40,13 @@ Další informace najdete na https://aka.ms/dotnet/aspire/container-runtime-unhe {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf index 85722a01e05..833a589d339 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf @@ -40,13 +40,13 @@ Weitere Informationen finden Sie unter https://aka.ms/dotnet/aspire/container-ru {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf index e9b519119ea..6f7a41972cf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf @@ -40,13 +40,13 @@ Para obtener más información, consulte https://aka.ms/dotnet/aspire/container- {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf index 9b977988d20..e66038fe9a4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf @@ -40,13 +40,13 @@ Pour plus d’informations, consultez https://aka.ms/dotnet/aspire/container-run {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf index 8f4e3f95f30..d458ab59b71 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf @@ -40,13 +40,13 @@ Per altre informazioni, vedere https://aka.ms/dotnet/aspire/container-runtime-un {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf index e7def0c230a..cdd138c65c4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf @@ -40,13 +40,13 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy{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. + リソースは、他のリソースが実行中で正常な状態になるのを待機しています。 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf index 14b5c563cee..3dcd40aaa35 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf @@ -40,13 +40,13 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy{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. + 해당 리소스는 다른 리소스가 실행 중이며 정상 상태가 될 때까지 대기하고 있습니다. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf index 9f810010d2e..cfb00b31c72 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf @@ -40,13 +40,13 @@ Aby uzyskać więcej informacji, zobacz https://aka.ms/dotnet/aspire/container-r {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf index 55ef73f387a..34cb37bc81b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf @@ -40,13 +40,13 @@ Para obter mais informações, consulte https://aka.ms/dotnet/aspire/container- {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf index dc72b335c0b..28ef29d4930 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf @@ -40,13 +40,13 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy{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. + Ресурс ожидает приведения других ресурсов в работоспособное состояние. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf index 2a24d74c19a..4112d67cebf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf @@ -40,13 +40,13 @@ Daha fazla bilgi için bkz. https://aka.ms/dotnet/aspire/container-runtime-unhea {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. diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf index 17510f64d4b..a92340db3a8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf @@ -40,13 +40,13 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy{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. + 资源正在等待其他资源处于运行正常状态。 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf index 5c4070da0ef..2b17f6afa8d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf @@ -40,13 +40,13 @@ For more information, see https://aka.ms/aspire/container-runtime-unhealthy{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. + 資源正在等待其他資源處於執行中且狀態良好。 diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 58e79da3ecc..96f573a3156 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -389,7 +389,50 @@ public void Render_StateDescription_ShowsAsResourceDetailEntry() var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); - Assert.Contains(Columns.StateColumnResourceWaiting, resourcePropertyGrid.TextContent); + Assert.Contains("Resource is waiting for dependencies.", resourcePropertyGrid.TextContent); + } + + [Fact] + public async Task Render_NotStartedStateDescription_ShowsStartAction() + { + ResourceSetupHelpers.SetupResourceDetails(this); + + var startCommand = new CommandViewModel( + CommandViewModel.StartCommand, + CommandViewModelState.Enabled, + displayName: "Start", + displayDescription: "Start resource", + confirmationMessage: string.Empty, + argumentInputs: [], + isHighlighted: true, + iconName: "Play", + iconVariant: IconVariant.Filled); + + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1", + state: KnownResourceState.NotStarted, + commands: ImmutableArray.Create(startCommand)); + + CommandViewModel? capturedCommand = null; + var cut = RenderComponent(builder => + { + builder.Add(p => p.Resource, resource); + builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); + builder.Add(p => p.CommandSelected, EventCallback.Factory.Create(this, c => capturedCommand = c)); + builder.Add(p => p.IsCommandExecuting, (_, _) => false); + }); + + var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; + Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); + Assert.Contains("Resource is not configured to start automatically.", resourcePropertyGrid.TextContent); + + var startButton = resourcePropertyGrid.QuerySelector("button.state-description-action"); + Assert.NotNull(startButton); + Assert.Equal("Start", startButton!.TextContent.Trim()); + + await startButton.ClickAsync(new MouseEventArgs()); + + Assert.Same(startCommand, capturedCommand); } [Fact] From cec54b4c7cba8f029524e5742804c2ee34209c3f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 14:34:58 -0400 Subject: [PATCH 03/20] Use localized strings in state detail tests --- .../Controls/ResourceDetailsTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 96f573a3156..8b76344b865 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Globalization; using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; @@ -389,7 +390,7 @@ public void Render_StateDescription_ShowsAsResourceDetailEntry() var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); - Assert.Contains("Resource is waiting for dependencies.", resourcePropertyGrid.TextContent); + Assert.Contains(Columns.StateColumnResourceWaiting, resourcePropertyGrid.TextContent); } [Fact] @@ -424,7 +425,7 @@ public async Task Render_NotStartedStateDescription_ShowsStartAction() var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); - Assert.Contains("Resource is not configured to start automatically.", resourcePropertyGrid.TextContent); + Assert.Contains(Columns.StateColumnResourceNotStarted, resourcePropertyGrid.TextContent); var startButton = resourcePropertyGrid.QuerySelector("button.state-description-action"); Assert.NotNull(startButton); @@ -468,7 +469,7 @@ public void Render_StateDescription_ShowsWaitingForDependenciesAsResourceDetailE var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); - Assert.Contains("Waiting for dependencies: nginx, redis.", resourcePropertyGrid.TextContent); + Assert.Contains(string.Format(CultureInfo.InvariantCulture, Columns.StateColumnResourceWaitingFor, "nginx, redis"), resourcePropertyGrid.TextContent); var links = resourcePropertyGrid.QuerySelectorAll("fluent-anchor"); Assert.Collection( From e9a81916d8310b6758e440dbac13e8989b939ae5 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 14:54:17 -0400 Subject: [PATCH 04/20] Refine state description start action --- .../ResourceStateDescriptionValue.razor | 18 ++++++++------- .../ResourceStateDescriptionValue.razor.cs | 3 +++ .../ResourceStateDescriptionValue.razor.css | 23 +++++++++---------- .../Resources/ControlsStrings.Designer.cs | 9 ++++++++ .../Resources/ControlsStrings.resx | 3 +++ .../Resources/xlf/ControlsStrings.cs.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.de.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.es.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.fr.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.it.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.ja.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.ko.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.pl.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.pt-BR.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.ru.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.tr.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.zh-Hans.xlf | 5 ++++ .../Resources/xlf/ControlsStrings.zh-Hant.xlf | 5 ++++ .../Controls/ResourceDetailsTests.cs | 4 ++-- 19 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor index 2029a82d99f..111d6eb37a9 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor @@ -1,4 +1,5 @@ -@using Aspire.Dashboard.Utils +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils @if (_waitingResources.Count == 0) { @@ -33,11 +34,12 @@ else @if (StartCommand is not null) { - + + + } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs index 57704df9771..a61b1a69703 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs @@ -43,6 +43,9 @@ public partial class ResourceStateDescriptionValue [Inject] public required IStringLocalizer Loc { get; init; } + [Inject] + public required IStringLocalizer ControlsLoc { get; init; } + protected override void OnParametersSet() { _waitingResources = []; diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css index f8f7061cdf5..bbebf959ea8 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css @@ -1,20 +1,19 @@ .state-description-action { - display: inline; - padding: 0; - border: 0; - background: none; - color: var(--accent-fill-rest); - text-decoration: underline; - cursor: pointer; + vertical-align: baseline; +} + +.state-description-action::part(control) { + min-width: 0; + height: auto; + padding: 0 4px; font: inherit; + text-decoration: none; } -.state-description-action:hover:not(:disabled) { - color: var(--accent-fill-hover); +.state-description-action::part(start) { + margin-inline-end: 2px; } -.state-description-action:disabled { - cursor: default; - opacity: var(--disabled-opacity); +.state-description-action[disabled]::part(control) { text-decoration: none; } \ 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..286c430cc11 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -860,6 +860,15 @@ public static string ResourceDetailsStateDescriptionHeader { return ResourceManager.GetString("ResourceDetailsStateDescriptionHeader", resourceCulture); } } + + /// + /// Looks up a localized string similar to Start now. + /// + public static string ResourceStateDescriptionStartNowAction { + get { + return ResourceManager.GetString("ResourceStateDescriptionStartNowAction", resourceCulture); + } + } /// /// Looks up a localized string similar to Type. diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 64599e3e356..58cb84d2fce 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -312,6 +312,9 @@ State details + + Start now + Events diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf index 6cf634ebaf9..0f7e49ddded 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -482,6 +482,11 @@ Prostředek + + Start now + Start now + + Resume incoming data Obnovit příchozí data diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index d2828a0e5ec..fecbf9de39e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -482,6 +482,11 @@ Ressource + + Start now + Start now + + Resume incoming data Fortsetzen eingehender Daten diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index d56e8237693..7327a2c6f80 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -482,6 +482,11 @@ Recurso + + Start now + Start now + + Resume incoming data Reanudar los datos entrantes diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index a08d31d51ed..9f786b9c866 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -482,6 +482,11 @@ Ressource + + Start now + Start now + + Resume incoming data Reprendre les données entrantes diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index 79514ad22a5..072bb3c18da 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -482,6 +482,11 @@ Risorsa + + Start now + Start now + + Resume incoming data Riprendi i dati in ingresso diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index 935bdf30f9a..9ab8757760b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -482,6 +482,11 @@ リソース + + Start now + Start now + + Resume incoming data 受信データを再開 diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index 6e1f067bcdc..c29ed45bd9c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -482,6 +482,11 @@ 리소스 + + Start now + Start now + + Resume incoming data 들어오는 데이터 다시 시작 diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index 81701dcb329..25be2d7d5e9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -482,6 +482,11 @@ Zasób + + Start now + Start now + + Resume incoming data Wznów dane przychodzące diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index 49d6196ed6d..da958e78674 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -482,6 +482,11 @@ Recurso + + Start now + Start now + + Resume incoming data Retomar dados recebidos diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index fce8cdd22ce..44d9f2d38b8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -482,6 +482,11 @@ Ресурс + + Start now + Start now + + Resume incoming data Возобновить входящие данные diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index 6b220adba94..80974f21a53 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -482,6 +482,11 @@ Kaynak + + Start now + Start now + + Resume incoming data Gelen verileri sürdür diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index 26e26e9b5b7..6a9ac56c911 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -482,6 +482,11 @@ 资源 + + Start now + Start now + + Resume incoming data 恢复传入数据 diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index e9c8854cfda..0098cf696f9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -482,6 +482,11 @@ 資源 + + Start now + Start now + + Resume incoming data 繼續傳入資料 diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 8b76344b865..5a065f541ac 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -427,9 +427,9 @@ public async Task Render_NotStartedStateDescription_ShowsStartAction() Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); Assert.Contains(Columns.StateColumnResourceNotStarted, resourcePropertyGrid.TextContent); - var startButton = resourcePropertyGrid.QuerySelector("button.state-description-action"); + var startButton = resourcePropertyGrid.QuerySelector("fluent-button.state-description-action"); Assert.NotNull(startButton); - Assert.Equal("Start", startButton!.TextContent.Trim()); + Assert.Contains(ControlsStrings.ResourceStateDescriptionStartNowAction, startButton!.TextContent); await startButton.ClickAsync(new MouseEventArgs()); From f5e6cbb1d20403b10d1b4829405961d2e39a5493 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 14:58:29 -0400 Subject: [PATCH 05/20] Add waiting dependency coverage for all wait types --- .../ResourceNotificationTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index 2f2dcf7d6fe..f27289340b0 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -394,6 +394,112 @@ await notificationService.PublishUpdateAsync(dependency2, s => s with 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() { From 3e277f12e5344d8f89705e541937db687670b27a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:00:43 -0400 Subject: [PATCH 06/20] Normalize state description punctuation --- src/Aspire.Dashboard/Resources/Columns.Designer.cs | 8 ++++---- src/Aspire.Dashboard/Resources/Columns.resx | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf | 14 +++++++------- .../Resources/xlf/Columns.pt-BR.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf | 14 +++++++------- src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf | 14 +++++++------- .../Resources/xlf/Columns.zh-Hans.xlf | 14 +++++++------- .../Resources/xlf/Columns.zh-Hant.xlf | 14 +++++++------- 15 files changed, 99 insertions(+), 99 deletions(-) diff --git a/src/Aspire.Dashboard/Resources/Columns.Designer.cs b/src/Aspire.Dashboard/Resources/Columns.Designer.cs index 191cb17347b..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 { diff --git a/src/Aspire.Dashboard/Resources/Columns.resx b/src/Aspire.Dashboard/Resources/Columns.resx index df82f7caee4..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,7 +155,7 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf index e894764e736..18798537364 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.cs.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf index 833a589d339..0306b99fef7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.de.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf index 6f7a41972cf..e5aa2e61afd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.es.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf index e66038fe9a4..e096e5f97ca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.fr.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf index d458ab59b71..84d878a14c0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.it.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf index cdd138c65c4..d7715df2ac2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ja.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf index 3dcd40aaa35..c2b854e278a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ko.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf index cfb00b31c72..75494e65117 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pl.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf index 34cb37bc81b..b19bfd1d788 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.pt-BR.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf index 28ef29d4930..7d14d2f76af 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.ru.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf index 4112d67cebf..18e98b0cd30 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.tr.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf index a92340db3a8..64fbb66adf3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hans.xlf @@ -19,24 +19,24 @@ 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 diff --git a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf index 2b17f6afa8d..cf4dfcbf96c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Columns.zh-Hant.xlf @@ -19,24 +19,24 @@ 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 From cc42763a2520aff4dd8c71ad23563e9ac40fe47c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:03:40 -0400 Subject: [PATCH 07/20] Align state description start action --- .../ResourceStateDescriptionValue.razor | 16 +++++----- .../ResourceStateDescriptionValue.razor.css | 31 ++++++++++++------- .../Controls/ResourceDetailsTests.cs | 2 +- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor index 111d6eb37a9..26af7644c3a 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor @@ -34,12 +34,12 @@ else @if (StartCommand is not null) { - + +} diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css index bbebf959ea8..7a3a2834705 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css @@ -1,19 +1,28 @@ .state-description-action { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: none; + color: var(--accent-foreground-rest); + cursor: pointer; + font: inherit; + font-weight: inherit; + line-height: inherit; + text-decoration: none; vertical-align: baseline; } -.state-description-action::part(control) { - min-width: 0; - height: auto; - padding: 0 4px; - font: inherit; - text-decoration: none; +.state-description-action:hover:not(:disabled) { + color: var(--accent-foreground-hover); } -.state-description-action::part(start) { - margin-inline-end: 2px; +.state-description-action:disabled { + cursor: default; + opacity: var(--disabled-opacity); } -.state-description-action[disabled]::part(control) { - text-decoration: none; -} \ No newline at end of file +.state-description-action-icon { + flex: none; +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 5a065f541ac..b3a14a8f212 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -427,7 +427,7 @@ public async Task Render_NotStartedStateDescription_ShowsStartAction() Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); Assert.Contains(Columns.StateColumnResourceNotStarted, resourcePropertyGrid.TextContent); - var startButton = resourcePropertyGrid.QuerySelector("fluent-button.state-description-action"); + var startButton = resourcePropertyGrid.QuerySelector("button.state-description-action"); Assert.NotNull(startButton); Assert.Contains(ControlsStrings.ResourceStateDescriptionStartNowAction, startButton!.TextContent); From 0dac35372ee59d26a5c8a311cf9bbb6fe0b36e8c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:09:43 -0400 Subject: [PATCH 08/20] Make state action fully inline --- .../ResourceStateDescriptionValue.razor.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css index 7a3a2834705..0325d24796e 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css @@ -1,7 +1,5 @@ .state-description-action { - display: inline-flex; - align-items: center; - gap: 4px; + display: inline; padding: 0; border: 0; background: none; @@ -24,5 +22,8 @@ } .state-description-action-icon { - flex: none; + width: 0.8em; + height: 0.8em; + margin-inline-end: 0.2em; + vertical-align: -0.05em; } From bdbc8412d61c25eeee08d1dc799ffa48e4f547f5 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:15:13 -0400 Subject: [PATCH 09/20] Align inline state action icon --- .../PropertyValues/ResourceStateDescriptionValue.razor | 2 +- .../PropertyValues/ResourceStateDescriptionValue.razor.css | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor index 26af7644c3a..78622b04f93 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor @@ -39,7 +39,7 @@ else disabled="@IsStartCommandDisabled" @onclick="OnStartCommandAsync" title="@StartCommandTitle"> - + } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css index 0325d24796e..9237618b529 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css @@ -22,8 +22,7 @@ } .state-description-action-icon { - width: 0.8em; - height: 0.8em; + display: inline; margin-inline-end: 0.2em; - vertical-align: -0.05em; + vertical-align: text-bottom; } From 0fae2e2244db558448ccf0685739ecd8667f36e5 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:15:45 -0400 Subject: [PATCH 10/20] Allow BasketService HTTP health checks --- playground/TestShop/BasketService/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/TestShop/BasketService/appsettings.json b/playground/TestShop/BasketService/appsettings.json index 0c784605fe0..0095ff7f627 100644 --- a/playground/TestShop/BasketService/appsettings.json +++ b/playground/TestShop/BasketService/appsettings.json @@ -9,7 +9,7 @@ "AllowedHosts": "*", "Kestrel": { "EndpointDefaults": { - "Protocols": "Http2" + "Protocols": "Http1AndHttp2" } }, "Aspire": { From 3818870475237019e1a0c2702bda46dc15f2937b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:19:21 -0400 Subject: [PATCH 11/20] Rename state action to Start anyway --- src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs | 2 +- src/Aspire.Dashboard/Resources/ControlsStrings.resx | 2 +- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf | 4 ++-- src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf | 4 ++-- .../Resources/xlf/ControlsStrings.zh-Hans.xlf | 4 ++-- .../Resources/xlf/ControlsStrings.zh-Hant.xlf | 4 ++-- 15 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 286c430cc11..3e56d09451f 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -862,7 +862,7 @@ public static string ResourceDetailsStateDescriptionHeader { } /// - /// Looks up a localized string similar to Start now. + /// Looks up a localized string similar to Start anyway. /// public static string ResourceStateDescriptionStartNowAction { get { diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 58cb84d2fce..d9635d8294d 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -313,7 +313,7 @@ State details - Start now + Start anyway Events diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf index 0f7e49ddded..21bc6aa61bf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index fecbf9de39e..b74db7db3d6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index 7327a2c6f80..4e71c656ebf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index 9f786b9c866..76c2b1afc97 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index 072bb3c18da..2a15920e674 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index 9ab8757760b..3806df3a66f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index c29ed45bd9c..c1ad9dbae82 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index 25be2d7d5e9..76aaaead3f3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index da958e78674..7f90a4d456b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index 44d9f2d38b8..3113e90754e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index 80974f21a53..0f7a5e4f0a4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index 6a9ac56c911..dcb96aecd55 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index 0098cf696f9..a8d8221c4e2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -483,8 +483,8 @@ - Start now - Start now + Start anyway + Start anyway From 39547d092aea79e4131b3a5b23af233af5ecf984 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 14 May 2026 15:32:54 -0400 Subject: [PATCH 12/20] Include waiting dependencies in wait cancellation --- .../ResourceNotificationService.cs | 10 ++++++ .../ResourceNotificationTests.cs | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index 20656718f00..98030138be0 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -561,6 +561,16 @@ async Task OnDependencyReadyAsync(string dependencyName) 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) { activity.SetError(ex); diff --git a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs index f27289340b0..9d23e9a8570 100644 --- a/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceNotificationTests.cs @@ -604,6 +604,41 @@ await notificationService.PublishUpdateAsync(resource, s => s with 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() { From 86751b59053787f601bf00a410a68fb74538cb34 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 15 May 2026 14:58:03 -0400 Subject: [PATCH 13/20] Address waiting dependency dashboard feedback --- .../TestShop/BasketService/appsettings.json | 2 +- .../ResourceStateDescriptionValue.razor | 45 ------ .../ResourceStateDescriptionValue.razor.cs | 143 ------------------ .../ResourceStateDescriptionValue.razor.css | 28 ---- .../Controls/ResourceDetails.razor.cs | 12 -- .../Resources/ControlsStrings.Designer.cs | 9 -- .../Resources/ControlsStrings.resx | 3 - .../Resources/xlf/ControlsStrings.cs.xlf | 5 - .../Resources/xlf/ControlsStrings.de.xlf | 5 - .../Resources/xlf/ControlsStrings.es.xlf | 5 - .../Resources/xlf/ControlsStrings.fr.xlf | 5 - .../Resources/xlf/ControlsStrings.it.xlf | 5 - .../Resources/xlf/ControlsStrings.ja.xlf | 5 - .../Resources/xlf/ControlsStrings.ko.xlf | 5 - .../Resources/xlf/ControlsStrings.pl.xlf | 5 - .../Resources/xlf/ControlsStrings.pt-BR.xlf | 5 - .../Resources/xlf/ControlsStrings.ru.xlf | 5 - .../Resources/xlf/ControlsStrings.tr.xlf | 5 - .../Resources/xlf/ControlsStrings.zh-Hans.xlf | 5 - .../Resources/xlf/ControlsStrings.zh-Hant.xlf | 5 - .../Controls/ResourceDetailsTests.cs | 42 +---- 21 files changed, 4 insertions(+), 345 deletions(-) delete mode 100644 src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor delete mode 100644 src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs delete mode 100644 src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css diff --git a/playground/TestShop/BasketService/appsettings.json b/playground/TestShop/BasketService/appsettings.json index 0095ff7f627..0c784605fe0 100644 --- a/playground/TestShop/BasketService/appsettings.json +++ b/playground/TestShop/BasketService/appsettings.json @@ -9,7 +9,7 @@ "AllowedHosts": "*", "Kestrel": { "EndpointDefaults": { - "Protocols": "Http1AndHttp2" + "Protocols": "Http2" } }, "Aspire": { diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor deleted file mode 100644 index 78622b04f93..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor +++ /dev/null @@ -1,45 +0,0 @@ -@using Aspire.Dashboard.Resources -@using Aspire.Dashboard.Utils - -@if (_waitingResources.Count == 0) -{ - -} -else -{ - - @for (var index = 0; index < _waitingResources.Count; index++) - { - var waitingResource = _waitingResources[index]; - - if (index > 0) - { - , - } - - if (waitingResource.Resource is { } resource) - { - - - - } - else - { - - } - } - -} - -@if (StartCommand is not null) -{ - - -} diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs deleted file mode 100644 index a61b1a69703..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.cs +++ /dev/null @@ -1,143 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Resources; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Localization; - -namespace Aspire.Dashboard.Components.Controls.PropertyValues; - -public partial class ResourceStateDescriptionValue -{ - private const string WaitingResourcePlaceholder = "{0}"; - private string _prefix = string.Empty; - private string _suffix = string.Empty; - private List _waitingResources = []; - private CommandViewModel? StartCommand { get; set; } - private bool IsStartCommandDisabled => StartCommand is null || StartCommand.State == CommandViewModelState.Disabled || OnExecuteCommandAsync is null || IsStartCommandExecuting; - private bool IsStartCommandExecuting => StartCommand is not null && (IsCommandExecuting?.Invoke(Resource, StartCommand) ?? false); - private string StartCommandTitle => StartCommand?.GetDisplayDescription() ?? StartCommand?.GetDisplayName() ?? string.Empty; - - [Parameter, EditorRequired] - public required string Value { get; set; } - - [Parameter, EditorRequired] - public required string HighlightText { get; set; } - - [Parameter, EditorRequired] - public required ResourceViewModel Resource { get; set; } - - [Parameter, EditorRequired] - public required IDictionary ResourceByName { get; set; } - - [Parameter] - public bool ShowHiddenResources { get; set; } - - [Parameter] - public Func? OnExecuteCommandAsync { get; set; } - - [Parameter] - public Func? IsCommandExecuting { get; set; } - - [Inject] - public required IStringLocalizer Loc { get; init; } - - [Inject] - public required IStringLocalizer ControlsLoc { get; init; } - - protected override void OnParametersSet() - { - _waitingResources = []; - _prefix = string.Empty; - _suffix = string.Empty; - StartCommand = GetVisibleStartCommand(); - - if (!Resource.TryGetWaitingForDependencies(out var dependencies)) - { - return; - } - - var waitingResourceNames = string.Join(", ", dependencies); - if (!TrySplitWaitingForFormat(waitingResourceNames, out _prefix, out _suffix)) - { - return; - } - - foreach (var dependency in dependencies) - { - if (TryGetVisibleResource(dependency, out var resource)) - { - _waitingResources.Add(new WaitingResource(resource, ResourceViewModel.GetResourceName(resource, ResourceByName))); - } - else - { - _waitingResources.Add(new WaitingResource(null, dependency)); - } - } - } - - private bool TrySplitWaitingForFormat(string waitingResourceNames, out string prefix, out string suffix) - { - var format = Loc[nameof(Columns.StateColumnResourceWaitingFor)].Value; - var placeholderIndex = format.IndexOf(WaitingResourcePlaceholder, StringComparison.Ordinal); - - if (placeholderIndex >= 0) - { - prefix = format[..placeholderIndex]; - suffix = format[(placeholderIndex + WaitingResourcePlaceholder.Length)..]; - return true; - } - - var resourceNamesIndex = Value.IndexOf(waitingResourceNames, StringComparison.Ordinal); - if (resourceNamesIndex >= 0) - { - prefix = Value[..resourceNamesIndex]; - suffix = Value[(resourceNamesIndex + waitingResourceNames.Length)..]; - return true; - } - - prefix = string.Empty; - suffix = string.Empty; - return false; - } - - private bool TryGetVisibleResource(string resourceName, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ResourceViewModel? resource) - { - if (ResourceViewModel.TryGetResourceByName(resourceName, ResourceByName, out resource) && !resource.IsResourceHidden(ShowHiddenResources)) - { - return true; - } - - resource = null; - return false; - } - - private CommandViewModel? GetVisibleStartCommand() - { - foreach (var command in Resource.Commands) - { - if (string.Equals(command.Name, CommandViewModel.StartCommand, StringComparisons.CommandName) && - command.State != CommandViewModelState.Hidden) - { - return command; - } - } - - return null; - } - - private Task OnStartCommandAsync() - { - if (StartCommand is not { } startCommand || - IsStartCommandDisabled || - OnExecuteCommandAsync is not { } onExecuteCommandAsync) - { - return Task.CompletedTask; - } - - return onExecuteCommandAsync(Resource, startCommand); - } - - private sealed record WaitingResource(ResourceViewModel? Resource, string DisplayName); -} diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css deleted file mode 100644 index 9237618b529..00000000000 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceStateDescriptionValue.razor.css +++ /dev/null @@ -1,28 +0,0 @@ -.state-description-action { - display: inline; - padding: 0; - border: 0; - background: none; - color: var(--accent-foreground-rest); - cursor: pointer; - font: inherit; - font-weight: inherit; - line-height: inherit; - text-decoration: none; - vertical-align: baseline; -} - -.state-description-action:hover:not(:disabled) { - color: var(--accent-foreground-hover); -} - -.state-description-action:disabled { - cursor: default; - opacity: var(--disabled-opacity); -} - -.state-description-action-icon { - display: inline; - margin-inline-end: 0.2em; - vertical-align: text-bottom; -} diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index 670e7bace12..33c3a385728 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -212,18 +212,6 @@ protected override void OnParametersSet() { Type = typeof(ResourceHealthStateValue), Parameters = { ["Resource"] = _resource } - }, - [StateDescriptionPropertyKey] = new ComponentMetadata - { - Type = typeof(ResourceStateDescriptionValue), - Parameters = - { - ["Resource"] = _resource, - ["ResourceByName"] = ResourceByName, - ["ShowHiddenResources"] = ShowHiddenResources, - ["OnExecuteCommandAsync"] = (Func)ExecuteResourceCommandAsync, - ["IsCommandExecuting"] = IsCommandExecuting, - } } }; diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs index 3e56d09451f..ff2df96724a 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.Designer.cs @@ -861,15 +861,6 @@ public static string ResourceDetailsStateDescriptionHeader { } } - /// - /// Looks up a localized string similar to Start anyway. - /// - public static string ResourceStateDescriptionStartNowAction { - get { - return ResourceManager.GetString("ResourceStateDescriptionStartNowAction", resourceCulture); - } - } - /// /// Looks up a localized string similar to Type. /// diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index d9635d8294d..64599e3e356 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -312,9 +312,6 @@ State details - - Start anyway - Events diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf index 21bc6aa61bf..6cf634ebaf9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.cs.xlf @@ -482,11 +482,6 @@ Prostředek - - Start anyway - Start anyway - - Resume incoming data Obnovit příchozí data diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf index b74db7db3d6..d2828a0e5ec 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.de.xlf @@ -482,11 +482,6 @@ Ressource - - Start anyway - Start anyway - - Resume incoming data Fortsetzen eingehender Daten diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf index 4e71c656ebf..d56e8237693 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.es.xlf @@ -482,11 +482,6 @@ Recurso - - Start anyway - Start anyway - - Resume incoming data Reanudar los datos entrantes diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf index 76c2b1afc97..a08d31d51ed 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.fr.xlf @@ -482,11 +482,6 @@ Ressource - - Start anyway - Start anyway - - Resume incoming data Reprendre les données entrantes diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf index 2a15920e674..79514ad22a5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.it.xlf @@ -482,11 +482,6 @@ Risorsa - - Start anyway - Start anyway - - Resume incoming data Riprendi i dati in ingresso diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf index 3806df3a66f..935bdf30f9a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ja.xlf @@ -482,11 +482,6 @@ リソース - - Start anyway - Start anyway - - Resume incoming data 受信データを再開 diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf index c1ad9dbae82..6e1f067bcdc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ko.xlf @@ -482,11 +482,6 @@ 리소스 - - Start anyway - Start anyway - - Resume incoming data 들어오는 데이터 다시 시작 diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf index 76aaaead3f3..81701dcb329 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pl.xlf @@ -482,11 +482,6 @@ Zasób - - Start anyway - Start anyway - - Resume incoming data Wznów dane przychodzące diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf index 7f90a4d456b..49d6196ed6d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.pt-BR.xlf @@ -482,11 +482,6 @@ Recurso - - Start anyway - Start anyway - - Resume incoming data Retomar dados recebidos diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf index 3113e90754e..fce8cdd22ce 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.ru.xlf @@ -482,11 +482,6 @@ Ресурс - - Start anyway - Start anyway - - Resume incoming data Возобновить входящие данные diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf index 0f7a5e4f0a4..6b220adba94 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.tr.xlf @@ -482,11 +482,6 @@ Kaynak - - Start anyway - Start anyway - - Resume incoming data Gelen verileri sürdür diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf index dcb96aecd55..26e26e9b5b7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hans.xlf @@ -482,11 +482,6 @@ 资源 - - Start anyway - Start anyway - - Resume incoming data 恢复传入数据 diff --git a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf index a8d8221c4e2..e9c8854cfda 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ControlsStrings.zh-Hant.xlf @@ -482,11 +482,6 @@ 資源 - - Start anyway - Start anyway - - Resume incoming data 繼續傳入資料 diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index b3a14a8f212..65199ce655a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -9,7 +9,6 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Tests.Shared; -using Aspire.Dashboard.Utils; using Aspire.Tests.Shared.DashboardModel; using Bunit; using Google.Protobuf.WellKnownTypes; @@ -394,46 +393,23 @@ public void Render_StateDescription_ShowsAsResourceDetailEntry() } [Fact] - public async Task Render_NotStartedStateDescription_ShowsStartAction() + public void Render_NotStartedStateDescription_ShowsDescription() { ResourceSetupHelpers.SetupResourceDetails(this); - var startCommand = new CommandViewModel( - CommandViewModel.StartCommand, - CommandViewModelState.Enabled, - displayName: "Start", - displayDescription: "Start resource", - confirmationMessage: string.Empty, - argumentInputs: [], - isHighlighted: true, - iconName: "Play", - iconVariant: IconVariant.Filled); - var resource = ModelTestHelpers.CreateResource( resourceName: "app1", - state: KnownResourceState.NotStarted, - commands: ImmutableArray.Create(startCommand)); + state: KnownResourceState.NotStarted); - CommandViewModel? capturedCommand = null; var cut = RenderComponent(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); - builder.Add(p => p.CommandSelected, EventCallback.Factory.Create(this, c => capturedCommand = c)); - builder.Add(p => p.IsCommandExecuting, (_, _) => false); }); var resourcePropertyGrid = cut.FindAll(".property-grid")[0]; Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); Assert.Contains(Columns.StateColumnResourceNotStarted, resourcePropertyGrid.TextContent); - - var startButton = resourcePropertyGrid.QuerySelector("button.state-description-action"); - Assert.NotNull(startButton); - Assert.Contains(ControlsStrings.ResourceStateDescriptionStartNowAction, startButton!.TextContent); - - await startButton.ClickAsync(new MouseEventArgs()); - - Assert.Same(startCommand, capturedCommand); } [Fact] @@ -471,19 +447,7 @@ public void Render_StateDescription_ShowsWaitingForDependenciesAsResourceDetailE Assert.Contains(ControlsStrings.ResourceDetailsStateDescriptionHeader, resourcePropertyGrid.TextContent); Assert.Contains(string.Format(CultureInfo.InvariantCulture, Columns.StateColumnResourceWaitingFor, "nginx, redis"), resourcePropertyGrid.TextContent); - var links = resourcePropertyGrid.QuerySelectorAll("fluent-anchor"); - Assert.Collection( - links, - link => - { - Assert.Equal(DashboardUrls.ResourcesUrl(resource: nginx.Name), link.GetAttribute("href")); - Assert.Equal("hypertext", link.GetAttribute("appearance")); - }, - link => - { - Assert.Equal(DashboardUrls.ResourcesUrl(resource: redis.Name), link.GetAttribute("href")); - Assert.Equal("hypertext", link.GetAttribute("appearance")); - }); + Assert.Empty(resourcePropertyGrid.QuerySelectorAll("fluent-anchor")); } [Fact] From a1238ec5da215d5befa45fa0ed68eb1be7e58bb3 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 15 May 2026 17:38:16 -0400 Subject: [PATCH 14/20] Address waiting dependency review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestShop/BasketService/appsettings.json | 5 -- .../TestShop/TestShop.AppHost/AppHost.cs | 58 ++++++++++++++----- .../Controls/ResourceDetails.razor.cs | 3 +- .../Model/ResourceMenuBuilder.cs | 8 ++- .../Model/ResourceMenuBuilderTests.cs | 48 +++++++++++++++ 5 files changed, 97 insertions(+), 25 deletions(-) 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..4afdbb43ffa 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") @@ -73,32 +78,53 @@ .WithManagementPlugin() .PublishAsContainer(); +// Test-only manual gate for dashboard waiting-state UX. It never starts automatically, +// so dependents stay in Waiting until the resource is explicitly started from the dashboard. +var manualStartGate = builder.AddContainer("manualstartgate", "alpine") + .WithEntrypoint("sleep") + .WithArgs("3600") + .WithExplicitStart(); + 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() + .WaitForStart(manualStartGate) + .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.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index 33c3a385728..e2e539ed4f0 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -307,7 +307,8 @@ private void UpdateResourceActionsMenu() IsCommandExecuting, showViewDetails: false, showConsoleLogsItem: true, - showUrls: true); + showUrls: true, + showStartCommand: false); } private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command) 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/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 { } From 9c20407adf71805179a6d088e09ea2f5ec62d1f8 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 18 May 2026 15:21:40 -0400 Subject: [PATCH 15/20] Address waiting dependency review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/ResourceSnapshotMapper.cs | 40 +++++++++++++ .../Controls/ResourceDetails.razor.cs | 2 +- .../Components/Pages/Resources.razor | 2 +- .../Model/Assistant/AIHelpers.cs | 2 +- .../Model/ResourceStateViewModel.cs | 6 +- .../Model/ResourceViewModelExtensions.cs | 47 +++++++++++++++ .../Model/TelemetryExportService.cs | 1 + .../AuxiliaryBackchannelRpcTarget.cs | 22 +++++++ .../Backchannel/BackchannelDataTypes.cs | 5 ++ .../Model/Serialization/ResourceJson.cs | 5 ++ .../ResourceSnapshotMapperTests.cs | 59 +++++++++++++++++++ .../Commands/DashboardRunCommandTests.cs | 2 +- .../TestProcessExecutionFactory.cs | 2 +- .../Controls/ResourceDetailsTests.cs | 4 +- .../Model/ResourceStateViewModelTests.cs | 55 +++++++++++++++++ .../Model/TelemetryExportServiceTests.cs | 12 ++++ 16 files changed, 257 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index 5823f72b312..77b29cacd82 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Aspire.Shared; using Aspire.Shared.Model; @@ -84,6 +85,8 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl p => p.Key, p => p.Value); + var waitingFor = GetResolvedWaitingForDependencies(snapshot, allSnapshots); + // Build relationships by matching DisplayName var relationships = new List(); foreach (var relationship in snapshot.Relationships) @@ -134,6 +137,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, @@ -152,6 +156,42 @@ 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) && + !string.IsNullOrWhiteSpace(waitingForProperty)) + { + waitingFor = waitingForProperty.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; + } + internal static bool IsCommandAvailableToApi(ResourceSnapshotCommand command) { return string.Equals(command.State, "Enabled", StringComparison.OrdinalIgnoreCase) && diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index e2e539ed4f0..b3803a39940 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -376,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/ResourceStateViewModel.cs b/src/Aspire.Dashboard/Model/ResourceStateViewModel.cs index 4b4c7099b6b..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,7 +132,9 @@ internal static string GetResourceStateTooltip(ResourceViewModel resource, IStri } else if (resource.IsWaiting()) { - if (resource.TryGetWaitingForDependencies(out var dependencies)) + if (allResources is not null + ? resource.TryGetResolvedWaitingForDependencies(allResources, out var dependencies) + : resource.TryGetWaitingForDependencies(out dependencies)) { return loc.GetString(nameof(Columns.StateColumnResourceWaitingFor), string.Join(", ", dependencies)); } diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs index ca82f223bb6..d90ae18fbad 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs @@ -104,6 +104,53 @@ public static bool TryGetWaitingForDependencies(this ResourceViewModel resource, 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/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index 70935323598..a1e92cbf420 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -725,6 +725,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, diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index e14074db899..34835239a86 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -865,6 +865,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu // Build properties dictionary from ResourcePropertySnapshot // Redact sensitive property values to avoid leaking secrets var properties = new Dictionary(); + string[]? waitingFor = null; foreach (var prop in snapshot.Properties) { // Redact sensitive property values @@ -884,6 +885,11 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu _ => prop.Value.ToString() }; properties[prop.Name] = stringValue; + + if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourceName)) + { + waitingFor = GetStringArrayPropertyValue(prop.Value); + } } // Build commands @@ -919,6 +925,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(), @@ -937,6 +944,21 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu }; } + 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 } strings + ? strings + : 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 d2904f4dbd6..5b44c358f18 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -987,6 +987,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"). /// diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index 6f0b89efebd..db79684b4d4 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -36,6 +36,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"). /// diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 8e527d78ce3..b40b1fbc617 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -94,6 +94,65 @@ public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly() Assert.Contains("localhost:18080", result.DashboardUrl); } + [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_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.Cli.Tests/Commands/DashboardRunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs index af5107fe98a..79056103ca3 100644 --- a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs @@ -340,7 +340,7 @@ public async Task DashboardRunCommand_WhenCancelled_DisplaysCancellationMessageA var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var (services, _, executionFactory) = CreateServicesWithLayout(workspace, interactionService: testInteractionService); executionFactory.CreateExecutionCallback = (_, _, _, options) => - new TestProcessExecution("fake", [], null, options, (_, _) => (0, null), () => 0) + new TestProcessExecution("fake", [], null, options, (_, _, _) => Task.FromResult((0, (string?)null)), () => 0) { WaitForExitAsyncCallback = async (processOptions, cancellationToken) => { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs index 087263c8396..ce9fb94e02d 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs @@ -140,7 +140,7 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) if (WaitForExitAsyncCallback is not null) { - return WaitForExitAsyncCallback(_options, cancellationToken); + return await WaitForExitAsyncCallback(_options, cancellationToken).ConfigureAwait(false); } var attempt = _attemptCounter(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 65199ce655a..8976b5e3de6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -417,7 +417,7 @@ public void Render_StateDescription_ShowsWaitingForDependenciesAsResourceDetailE { ResourceSetupHelpers.SetupResourceDetails(this); - var nginx = ModelTestHelpers.CreateResource(resourceName: "nginx"); + var nginx = ModelTestHelpers.CreateResource(resourceName: "nginx-abcxyz", displayName: "nginx"); var redis = ModelTestHelpers.CreateResource(resourceName: "redis"); var resource = ModelTestHelpers.CreateResource( @@ -427,7 +427,7 @@ public void Render_StateDescription_ShowsWaitingForDependenciesAsResourceDetailE { [KnownProperties.Resource.WaitingFor] = new( KnownProperties.Resource.WaitingFor, - Value.ForList(Value.ForString("nginx"), Value.ForString("redis")), + Value.ForList(Value.ForString("nginx-abcxyz"), Value.ForString("redis")), isValueSensitive: false, knownProperty: null, priority: 0) diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs index 2e6ae608273..6b78947b0f3 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceStateViewModelTests.cs @@ -130,4 +130,59 @@ public void WaitingResourceTooltipIncludesWaitingForDependenciesWhenPresent() 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..b69fca4068d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -13,6 +13,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 +1185,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 +1208,8 @@ 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.Urls); Assert.Single(deserialized.Urls); From 12452eb2d7dce6b7daf6f1a44f7a4a1891bc389a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 18 May 2026 15:40:07 -0400 Subject: [PATCH 16/20] Fix TestShop build after waiting dependency updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/AuxiliaryBackchannelRpcTarget.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 7a2fee7a2cf..77b095adc65 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; @@ -855,7 +856,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu }; properties[prop.Name] = stringValue; - if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourceName)) + if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName)) { waitingFor = GetStringArrayPropertyValue(prop.Value); } @@ -923,8 +924,8 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu 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 } strings - ? strings + } is { Length: > 0 } values + ? values : null; } From 00dcf27b439dbc0578b367e9d99fa34831f17e41 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 18 May 2026 15:43:07 -0400 Subject: [PATCH 17/20] Remove manual TestShop start gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/TestShop/TestShop.AppHost/AppHost.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/playground/TestShop/TestShop.AppHost/AppHost.cs b/playground/TestShop/TestShop.AppHost/AppHost.cs index 4afdbb43ffa..a55a391f4ea 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -78,13 +78,6 @@ .WithManagementPlugin() .PublishAsContainer(); -// Test-only manual gate for dashboard waiting-state UX. It never starts automatically, -// so dependents stay in Waiting until the resource is explicitly started from the dashboard. -var manualStartGate = builder.AddContainer("manualstartgate", "alpine") - .WithEntrypoint("sleep") - .WithArgs("3600") - .WithExplicitStart(); - var basketService = builder.AddProject("basketservice", @"..\BasketService\BasketService.csproj") .WithReference(basketCache) .WaitFor(basketCache) @@ -94,7 +87,6 @@ var frontend = builder.AddProject("frontend") .WithExternalHttpEndpoints() - .WaitForStart(manualStartGate) .WithReference(basketService) .WaitFor(basketService) .WithReference(catalogService) From 3126b2bc62c8c2f7a520b27e0f94fff8fdd217f0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 18 May 2026 15:46:44 -0400 Subject: [PATCH 18/20] Fix waiting dependency property export Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Model/TelemetryExportService.cs | 28 ++++++++++++++++++- .../Model/TelemetryExportServiceTests.cs | 2 ++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index a1e92cbf420..66460471efd 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -763,7 +763,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 => ConvertPropertyValueToString(p.Value.Value)) : null, Relationships = relationshipsJson, Commands = resource.Commands.Length > 0 @@ -783,6 +783,32 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea return resourceJson; } + private static string? ConvertPropertyValueToString(Google.Protobuf.WellKnownTypes.Value value) + { + if (value.TryConvertToString(out var stringValue)) + { + return stringValue; + } + + if (value.ListValue is null) + { + return null; + } + + var values = new string[value.ListValue.Values.Count]; + for (var i = 0; i < value.ListValue.Values.Count; i++) + { + if (!value.ListValue.Values[i].TryConvertToString(out var elementValue)) + { + return null; + } + + values[i] = elementValue; + } + + return string.Join(", ", values); + } + /// /// Gets the destination name for a span by resolving uninstrumented peer names. /// diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index b69fca4068d..174f9f36f78 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -1210,6 +1210,8 @@ public void ConvertResourceToJson_ReturnsExpectedJson() Assert.Equal("Running", deserialized.State); Assert.NotNull(deserialized.WaitingFor); Assert.Equal(["dependency"], deserialized.WaitingFor); + Assert.NotNull(deserialized.Properties); + Assert.Equal("dependency-resource", deserialized.Properties[KnownProperties.Resource.WaitingFor]); Assert.NotNull(deserialized.Urls); Assert.Single(deserialized.Urls); From d05e4da90f830b586a16e6c716e9d69994bbe4d9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 18 May 2026 16:00:06 -0400 Subject: [PATCH 19/20] Preserve structured resource properties in JSON Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BackchannelJsonSerializerContext.cs | 1 + .../Backchannel/ResourceSnapshotMapper.cs | 30 ++++++++++-- src/Aspire.Cli/Commands/DescribeCommand.cs | 3 ++ src/Aspire.Cli/Commands/PsCommand.cs | 3 ++ src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 3 ++ .../ResourceJsonSerializerContext.cs | 3 ++ .../Model/TelemetryExportService.cs | 24 ++++------ .../AuxiliaryBackchannelRpcTarget.cs | 48 ++++++++++++++----- .../Backchannel/BackchannelDataTypes.cs | 2 +- .../Model/Serialization/ResourceJson.cs | 3 +- .../OtlpJsonSerializerContext.cs | 3 ++ .../ResourceSnapshotMapperTests.cs | 35 ++++++++++++++ .../Model/TelemetryExportServiceTests.cs | 5 +- 13 files changed, 131 insertions(+), 32 deletions(-) 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 77b29cacd82..fd5330d29b4 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -1,6 +1,7 @@ // 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; @@ -83,7 +84,7 @@ 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); @@ -121,7 +122,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; @@ -161,9 +165,10 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl var waitingFor = snapshot.WaitingFor; if (waitingFor is not { Length: > 0 } && snapshot.Properties.TryGetValue(KnownProperties.Resource.WaitingFor, out var waitingForProperty) && - !string.IsNullOrWhiteSpace(waitingForProperty)) + TryConvertJsonNodeToString(waitingForProperty, out var waitingForPropertyString) && + !string.IsNullOrWhiteSpace(waitingForPropertyString)) { - waitingFor = waitingForProperty.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + waitingFor = waitingForPropertyString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } if (waitingFor is not { Length: > 0 }) @@ -192,6 +197,23 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl 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/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 66460471efd..236f34e7d8a 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; @@ -763,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 => ConvertPropertyValueToString(p.Value.Value)) + p => ConvertPropertyValueToJsonNode(p.Value.Value)) : null, Relationships = relationshipsJson, Commands = resource.Commands.Length > 0 @@ -783,30 +784,25 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea return resourceJson; } - private static string? ConvertPropertyValueToString(Google.Protobuf.WellKnownTypes.Value value) + private static JsonNode? ConvertPropertyValueToJsonNode(Google.Protobuf.WellKnownTypes.Value value) { if (value.TryConvertToString(out var stringValue)) { - return stringValue; + return JsonValue.Create(stringValue); } - if (value.ListValue is null) + if (value.ListValue is not null) { - return null; - } - - var values = new string[value.ListValue.Values.Count]; - for (var i = 0; i < value.ListValue.Values.Count; i++) - { - if (!value.ListValue.Values[i].TryConvertToString(out var elementValue)) + var array = new JsonArray(); + foreach (var element in value.ListValue.Values) { - return null; + array.Add(ConvertPropertyValueToJsonNode(element)); } - values[i] = elementValue; + return array; } - return string.Join(", ", values); + return null; } /// diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 77b095adc65..e29bc45a215 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -834,7 +834,7 @@ 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) { @@ -845,16 +845,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu continue; } - // Convert value to string representation - var stringValue = prop.Value switch - { - 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; + properties[prop.Name] = ConvertPropertyValueToJsonNode(prop.Value); if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName)) { @@ -914,6 +905,41 @@ 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 diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 7f7f703107d..c4d64a1f1a3 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -1056,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/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index 83fc1d035aa..4d34bff3725 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; @@ -100,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 b40b1fbc617..152cfb282dd 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; @@ -120,6 +123,38 @@ public void MapToResourceJson_ResolvesWaitingForDependencies() 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() { diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 174f9f36f78..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; @@ -1211,7 +1212,9 @@ public void ConvertResourceToJson_ReturnsExpectedJson() Assert.NotNull(deserialized.WaitingFor); Assert.Equal(["dependency"], deserialized.WaitingFor); Assert.NotNull(deserialized.Properties); - Assert.Equal("dependency-resource", deserialized.Properties[KnownProperties.Resource.WaitingFor]); + 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); From 0679b597395bcd69529169ffc664f2cb58a33a8d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 18 May 2026 23:35:03 -0400 Subject: [PATCH 20/20] Fix backchannel property test for JSON values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/AuxiliaryBackchannelRpcTargetTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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);