diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 5b47ff4cdc4..76868939098 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index fe50ca47efb..c3102597dba 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -21,7 +21,8 @@ internal class AppHostRpcTarget( IServiceProvider serviceProvider, IDistributedApplicationEventing eventing, PublishingActivityProgressReporter activityReporter, - IHostApplicationLifetime lifetime + IHostApplicationLifetime lifetime, + DistributedApplicationOptions options ) { public async IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken) @@ -101,6 +102,25 @@ public Task PingAsync(long timestamp, CancellationToken cancellationToken) public Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync() { + return GetDashboardUrlsAsync(CancellationToken.None); + } + + public async Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync(CancellationToken cancellationToken) + { + if (!options.DashboardEnabled) + { + logger.LogError("Dashboard URL requested but dashboard is disabled."); + throw new InvalidOperationException("Dashboard URL requested but dashboard is disabled."); + } + + // Wait for the dashboard to be healthy before we return the URL. This is to avoid + // a race condition when using Codespaces or devcontainers where the dashboard URL + // is displayed before the dashboard port forwarding is actually configured. It is + // also a point of friction to show the URL before the dashboard is ready to be used + // when using Devcontainers/Codespaces because people think that something isn't working + // when in fact they just need to refresh the page. + await resourceNotificationService.WaitForResourceHealthyAsync(KnownResourceNames.AspireDashboard, cancellationToken).ConfigureAwait(false); + var dashboardOptions = serviceProvider.GetService>(); if (dashboardOptions is null) @@ -122,11 +142,11 @@ public Task PingAsync(long timestamp, CancellationToken cancellationToken) if (baseUrlWithLoginToken == codespacesUrlWithLoginToken) { - return Task.FromResult<(string, string?)>((baseUrlWithLoginToken, null)); + return (baseUrlWithLoginToken, null); } else { - return Task.FromResult((baseUrlWithLoginToken, codespacesUrlWithLoginToken)); + return (baseUrlWithLoginToken, codespacesUrlWithLoginToken); } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs b/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs index 2d22a5db2d1..bd9e337b06f 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs @@ -127,7 +127,6 @@ private void AddDashboardResource(DistributedApplicationModel model) nameGenerator.EnsureDcpInstancesPopulated(dashboardResource); ConfigureAspireDashboardResource(dashboardResource); - // Make the dashboard first in the list so it starts as fast as possible. model.Resources.Insert(0, dashboardResource); } @@ -179,6 +178,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) dashboardResource.Annotations.Add(new ResourceSnapshotAnnotation(snapshot)); dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(ConfigureEnvironmentVariables)); + dashboardResource.Annotations.Add(new HealthCheckAnnotation(KnownHealthCheckNames.DasboardHealthCheck)); } internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext context) diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 9eb7c04ebc4..fcab487bad2 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -19,6 +19,7 @@ using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Orchestrator; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -331,6 +332,20 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddLifecycleHook(); _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureDefaultDashboardOptions>()); _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ValidateDashboardOptions>()); + + // Dashboard health check. + _innerBuilder.Services.AddHealthChecks().AddUrlGroup(sp => { + + var dashboardOptions = sp.GetRequiredService>().Value; + if (StringUtils.TryGetUriFromDelimitedString(dashboardOptions.DashboardUrl, ";", out var firstDashboardUrl)) + { + return firstDashboardUrl; + } + else + { + throw new DistributedApplicationException($"The dashboard resource '{KnownResourceNames.AspireDashboard}' does not have endpoints."); + } + }, KnownHealthCheckNames.DasboardHealthCheck); } if (options.EnableResourceLogging) diff --git a/src/Shared/KnownHealthCheckNames.cs b/src/Shared/KnownHealthCheckNames.cs new file mode 100644 index 00000000000..5bf0276584c --- /dev/null +++ b/src/Shared/KnownHealthCheckNames.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire; + +internal static class KnownHealthCheckNames +{ + /// + /// Common name for dashboard health check. + /// + public const string DasboardHealthCheck = "aspire_dashboard_check"; +}