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