diff --git a/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs b/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs
index c0af7c0f417..a1ae0f42aa7 100644
--- a/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs
+++ b/src/Aspire.Dashboard/Components/Controls/SummaryDetailsView.razor.cs
@@ -4,6 +4,7 @@
using System.Globalization;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Model;
+using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -293,13 +294,13 @@ static void GetPanelSizes(
private string GetSizeStorageKey()
{
var viewKey = ViewKey ?? NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
- return $"Aspire_SplitterSize_{Orientation}_{viewKey}";
+ return BrowserStorageKeys.SplitterSizeKey(viewKey, Orientation);
}
private string GetOrientationStorageKey()
{
var viewKey = ViewKey ?? NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
- return $"Aspire_SplitterOrientation_{viewKey}";
+ return BrowserStorageKeys.SplitterOrientationKey(viewKey);
}
public void Dispose()
diff --git a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor
index 4689efe062b..2ad455a369a 100644
--- a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor
+++ b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor
@@ -1,7 +1,7 @@
-
-
- @if (_showUserProfileMenu)
- {
+@if (_showUserProfileMenu)
+{
+
+
- }
-
-
+
+
+}
diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs
index e038d367fea..2ad971a5481 100644
--- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs
+++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs
@@ -64,6 +64,9 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable
[Inject]
public required IOptionsMonitor Options { get; init; }
+ [Inject]
+ public required ILocalStorage LocalStorage { get; init; }
+
[CascadingParameter]
public required ViewportInformation ViewportInformation { get; set; }
@@ -102,22 +105,32 @@ protected override async Task OnInitializedAsync()
if (Options.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured)
{
- // ShowMessageBarAsync must come after an await. Otherwise it will NRE.
- // I think this order allows the message bar provider to be fully initialized.
- await MessageService.ShowMessageBarAsync(options =>
+ var dismissedResult = await LocalStorage.GetUnprotectedAsync(BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey);
+ var skipMessage = dismissedResult.Success && dismissedResult.Value;
+
+ if (!skipMessage)
{
- options.Title = Loc[nameof(Resources.Layout.MessageTelemetryTitle)];
- options.Body = Loc[nameof(Resources.Layout.MessageTelemetryBody)];
- options.Link = new()
+ // ShowMessageBarAsync must come after an await. Otherwise it will NRE.
+ // I think this order allows the message bar provider to be fully initialized.
+ await MessageService.ShowMessageBarAsync(options =>
{
- Text = Loc[nameof(Resources.Layout.MessageTelemetryLink)],
- Href = "https://aka.ms/dotnet/aspire/telemetry-unsecured",
- Target = "_blank"
- };
- options.Intent = MessageIntent.Warning;
- options.Section = MessageBarSection;
- options.AllowDismiss = true;
- });
+ options.Title = Loc[nameof(Resources.Layout.MessageTelemetryTitle)];
+ options.Body = Loc[nameof(Resources.Layout.MessageTelemetryBody)];
+ options.Link = new()
+ {
+ Text = Loc[nameof(Resources.Layout.MessageTelemetryLink)],
+ Href = "https://aka.ms/dotnet/aspire/telemetry-unsecured",
+ Target = "_blank"
+ };
+ options.Intent = MessageIntent.Warning;
+ options.Section = MessageBarSection;
+ options.AllowDismiss = true;
+ options.OnClose = async m =>
+ {
+ await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey, true);
+ };
+ });
+ }
}
}
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
index 5eaa0181c32..9fb01462693 100644
--- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
@@ -57,7 +57,7 @@ public sealed partial class ConsoleLogs : ComponentBase, IAsyncDisposable, IPage
public ConsoleLogsViewModel PageViewModel { get; set; } = null!;
public string BasePath => DashboardUrls.ConsoleLogBasePath;
- public string SessionStorageKey => "Aspire_ConsoleLogs_PageState";
+ public string SessionStorageKey => BrowserStorageKeys.ConsoleLogsPageState;
protected override async Task OnInitializedAsync()
{
diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs
index d641b44f9d1..8abd1aafb88 100644
--- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs
@@ -27,7 +27,7 @@ public partial class Metrics : IDisposable, IPageWithSessionAndUrlState DashboardUrls.MetricsBasePath;
- public string SessionStorageKey => "Aspire_Metrics_PageState";
+ public string SessionStorageKey => BrowserStorageKeys.MetricsPageState;
public MetricsViewModel PageViewModel { get; set; } = null!;
[Parameter]
diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
index 1b8abe3c087..5f8757434d4 100644
--- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
@@ -44,7 +44,7 @@ public partial class StructuredLogs : IPageWithSessionAndUrlState DashboardUrls.StructuredLogsBasePath;
- public string SessionStorageKey => "Aspire_StructuredLogs_PageState";
+ public string SessionStorageKey => BrowserStorageKeys.StructuredLogsPageState;
public StructuredLogsPageViewModel PageViewModel { get; set; } = null!;
[Inject]
diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
index 49fb9a4ddef..6e356fa1eca 100644
--- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
@@ -40,7 +40,7 @@ public partial class Traces : IPageWithSessionAndUrlState "Aspire_Traces_PageState";
+ public string SessionStorageKey => BrowserStorageKeys.TracesPageState;
public string BasePath => DashboardUrls.TracesBasePath;
public TracesPageViewModel PageViewModel { get; set; } = null!;
diff --git a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs
new file mode 100644
index 00000000000..f734c4c6342
--- /dev/null
+++ b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.FluentUI.AspNetCore.Components;
+
+namespace Aspire.Dashboard.Utils;
+
+internal static class BrowserStorageKeys
+{
+ public const string UnsecuredTelemetryMessageDismissedKey = "Aspire_Telemetry_UnsecuredMessageDismissed";
+
+ public const string TracesPageState = "Aspire_PageState_Traces";
+ public const string StructuredLogsPageState = "Aspire_PageState_StructuredLogs";
+ public const string MetricsPageState = "Aspire_PageState_Metrics";
+ public const string ConsoleLogsPageState = "Aspire_PageState_ConsoleLogs";
+
+ public static string SplitterOrientationKey(string viewKey)
+ {
+ return $"Aspire_SplitterOrientation_{viewKey}";
+ }
+
+ public static string SplitterSizeKey(string viewKey, Orientation orientation)
+ {
+ return $"Aspire_SplitterSize_{orientation}_{viewKey}";
+ }
+}
diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs
new file mode 100644
index 00000000000..cf424a271f2
--- /dev/null
+++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs
@@ -0,0 +1,162 @@
+// 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.Components.Layout;
+using Aspire.Dashboard.Components.Resize;
+using Aspire.Dashboard.Components.Tests.Shared;
+using Aspire.Dashboard.Configuration;
+using Aspire.Dashboard.Model;
+using Aspire.Dashboard.Model.BrowserStorage;
+using Aspire.Dashboard.Utils;
+using Bunit;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.FluentUI.AspNetCore.Components;
+using Microsoft.FluentUI.AspNetCore.Components.Components.Tooltip;
+using Xunit;
+
+namespace Aspire.Dashboard.Components.Tests.Layout;
+
+[UseCulture("en-US")]
+public partial class MainLayoutTests : TestContext
+{
+ [Fact]
+ public async Task OnInitialize_UnsecuredOtlp_NotDismissed_DisplayMessageBar()
+ {
+ // Arrange
+ var testLocalStorage = new TestLocalStorage();
+ var messageService = new MessageService();
+
+ SetupMainLayoutServices(localStorage: testLocalStorage, messageService: messageService);
+
+ Message? message = null;
+ var messageShownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ messageService.OnMessageItemsUpdatedAsync += () =>
+ {
+ message = messageService.AllMessages.Single();
+ messageShownTcs.TrySetResult();
+ return Task.CompletedTask;
+ };
+
+ testLocalStorage.OnGetUnprotectedAsync = key =>
+ {
+ if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey)
+ {
+ return (false, false);
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected key.");
+ }
+ };
+
+ var dismissedSettingSetTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ testLocalStorage.OnSetUnprotectedAsync = (key, value) =>
+ {
+ if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey)
+ {
+ dismissedSettingSetTcs.TrySetResult((bool)value!);
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected key.");
+ }
+ };
+
+ // Act
+ var cut = RenderComponent(builder =>
+ {
+ builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
+ });
+
+ // Assert
+ await messageShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.NotNull(message);
+
+ message.Close();
+
+ Assert.True(await dismissedSettingSetTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)));
+ }
+
+ [Fact]
+ public async Task OnInitialize_UnsecuredOtlp_Dismissed_NoMessageBar()
+ {
+ // Arrange
+ var testLocalStorage = new TestLocalStorage();
+ var messageService = new MessageService();
+
+ SetupMainLayoutServices(localStorage: testLocalStorage, messageService: messageService);
+
+ var messageShownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ messageService.OnMessageItemsUpdatedAsync += () =>
+ {
+ messageShownTcs.TrySetResult();
+ return Task.CompletedTask;
+ };
+
+ testLocalStorage.OnGetUnprotectedAsync = key =>
+ {
+ if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey)
+ {
+ return (true, true);
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected key.");
+ }
+ };
+
+ // Act
+ var cut = RenderComponent(builder =>
+ {
+ builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
+ });
+
+ // Assert
+ var timeoutTask = Task.Delay(100);
+ var completedTask = await Task.WhenAny(messageShownTcs.Task, timeoutTask).WaitAsync(TimeSpan.FromSeconds(5));
+
+ // It's hard to test something not happening.
+ // In this case of checking for a message, apply a small display and then double check that no message was displayed.
+ Assert.True(completedTask != messageShownTcs.Task, "No message bar should be displayed.");
+ Assert.Empty(messageService.AllMessages);
+ }
+
+ private void SetupMainLayoutServices(TestLocalStorage? localStorage = null, MessageService? messageService = null)
+ {
+ Services.AddLocalization();
+ Services.AddOptions();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton(localStorage ?? new TestLocalStorage());
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton(messageService ?? new MessageService());
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.Configure(o => o.Otlp.AuthMode = OtlpAuthMode.Unsecured);
+
+ var version = typeof(FluentMain).Assembly.GetName().Version!;
+
+ var overflowModule = JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Overflow/FluentOverflow.razor.js", version));
+ overflowModule.SetupVoid("fluentOverflowInitialize", _ => true);
+
+ var anchorModule = JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Anchor/FluentAnchor.razor.js", version));
+
+ var themeModule = JSInterop.SetupModule("/js/app-theme.js");
+
+ JSInterop.SetupModule("window.registerGlobalKeydownListener", _ => true);
+ JSInterop.SetupModule("window.registerOpenTextVisualizerOnClick", _ => true);
+
+ JSInterop.Setup("window.getBrowserTimeZone").SetResult("abc");
+ }
+
+ private static string GetFluentFile(string filePath, Version version)
+ {
+ return $"{filePath}?v={version}";
+ }
+}
diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs
new file mode 100644
index 00000000000..8628b100542
--- /dev/null
+++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs
@@ -0,0 +1,33 @@
+// 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;
+
+namespace Aspire.Dashboard.Components.Tests.Shared;
+
+public class TestDashboardClient : IDashboardClient
+{
+ public bool IsEnabled { get; }
+ public Task WhenConnected { get; } = Task.CompletedTask;
+ public string ApplicationName { get; } = "TestApp";
+
+ public ValueTask DisposeAsync()
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task SubscribeResourcesAsync(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs
index 1f9375ccdef..96b29fc5902 100644
--- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs
+++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs
@@ -7,6 +7,9 @@ namespace Aspire.Dashboard.Components.Tests.Shared;
public sealed class TestLocalStorage : ILocalStorage
{
+ public Func? OnGetUnprotectedAsync { get; set; }
+ public Action? OnSetUnprotectedAsync { get; set; }
+
public Task> GetAsync(string key)
{
return Task.FromResult(new StorageResult(Success: false, Value: default));
@@ -14,6 +17,11 @@ public Task> GetAsync(string key)
public Task> GetUnprotectedAsync(string key)
{
+ if (OnGetUnprotectedAsync is { } callback)
+ {
+ var (success, value) = callback(key);
+ return Task.FromResult(new StorageResult(Success: success, Value: (T)(value ?? default(T))!));
+ }
return Task.FromResult(new StorageResult(Success: false, Value: default));
}
@@ -24,6 +32,10 @@ public Task SetAsync(string key, T value)
public Task SetUnprotectedAsync(string key, T value)
{
+ if (OnSetUnprotectedAsync is { } callback)
+ {
+ callback(key, value);
+ }
return Task.CompletedTask;
}
}