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";
+}