Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 7 additions & 7 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<AuthorizeView>
<Authorized>
@if (_showUserProfileMenu)
{
@if (_showUserProfileMenu)
Copy link
Member Author

@JamesNK JamesNK Aug 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only include AuthorizeView on the page if the user profile should be displayed (which means the auth mode needs to be a certain value).

This avoids the need to include various authz services in tests.

{
<AuthorizeView>
<Authorized>
<div class="profile-menu-container">
<FluentProfileMenu Initials="@_initials"
EMail="@_username"
Expand All @@ -28,6 +28,6 @@
</ChildContent>
</FluentProfileMenu>
</div>
}
</Authorized>
</AuthorizeView>
</Authorized>
</AuthorizeView>
}
41 changes: 27 additions & 14 deletions src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable
[Inject]
public required IOptionsMonitor<DashboardOptions> Options { get; init; }

[Inject]
public required ILocalStorage LocalStorage { get; init; }

[CascadingParameter]
public required ViewportInformation ViewportInformation { get; set; }

Expand Down Expand Up @@ -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<bool>(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);
};
});
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public partial class Metrics : IDisposable, IPageWithSessionAndUrlState<Metrics.
private Subscription? _metricsSubscription;

public string BasePath => DashboardUrls.MetricsBasePath;
public string SessionStorageKey => "Aspire_Metrics_PageState";
public string SessionStorageKey => BrowserStorageKeys.MetricsPageState;
public MetricsViewModel PageViewModel { get; set; } = null!;

[Parameter]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public partial class StructuredLogs : IPageWithSessionAndUrlState<StructuredLogs
private GridColumnManager _manager = null!;

public string BasePath => DashboardUrls.StructuredLogsBasePath;
public string SessionStorageKey => "Aspire_StructuredLogs_PageState";
public string SessionStorageKey => BrowserStorageKeys.StructuredLogsPageState;
public StructuredLogsPageViewModel PageViewModel { get; set; } = null!;

[Inject]
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public partial class Traces : IPageWithSessionAndUrlState<TracesPageViewModel, T
private AspirePageContentLayout? _contentLayout;
private GridColumnManager _manager = null!;

public string SessionStorageKey => "Aspire_Traces_PageState";
public string SessionStorageKey => BrowserStorageKeys.TracesPageState;
public string BasePath => DashboardUrls.TracesBasePath;
public TracesPageViewModel PageViewModel { get; set; } = null!;

Expand Down
26 changes: 26 additions & 0 deletions src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs
Original file line number Diff line number Diff line change
@@ -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}";
}
}
162 changes: 162 additions & 0 deletions tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
testLocalStorage.OnSetUnprotectedAsync = (key, value) =>
{
if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey)
{
dismissedSettingSetTcs.TrySetResult((bool)value!);
}
else
{
throw new InvalidOperationException("Unexpected key.");
}
};

// Act
var cut = RenderComponent<MainLayout>(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<MainLayout>(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<ThemeManager>();
Services.AddSingleton<IDialogService, DialogService>();
Services.AddSingleton<IDashboardClient, TestDashboardClient>();
Services.AddSingleton<ILocalStorage>(localStorage ?? new TestLocalStorage());
Services.AddSingleton<IEffectiveThemeResolver, TestEffectiveThemeResolver>();
Services.AddSingleton<ShortcutManager>();
Services.AddSingleton<BrowserTimeProvider, TestTimeProvider>();
Services.AddSingleton<IMessageService>(messageService ?? new MessageService());
Services.AddSingleton<LibraryConfiguration>();
Services.AddSingleton<ITooltipService, TooltipService>();
Services.AddSingleton<IToastService, ToastService>();
Services.AddSingleton<GlobalState>();
Services.Configure<DashboardOptions>(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<string>("window.getBrowserTimeZone").SetResult("abc");
}

private static string GetFluentFile(string filePath, Version version)
{
return $"{filePath}?v={version}";
}
}
Original file line number Diff line number Diff line change
@@ -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<ResourceCommandResponseViewModel> ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}

public IAsyncEnumerable<IReadOnlyList<ResourceLogLine>>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}

public Task<ResourceViewModelSubscription> SubscribeResourcesAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
12 changes: 12 additions & 0 deletions tests/Aspire.Dashboard.Components.Tests/Shared/TestLocalStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ namespace Aspire.Dashboard.Components.Tests.Shared;

public sealed class TestLocalStorage : ILocalStorage
{
public Func<string, (bool Success, object? Value)>? OnGetUnprotectedAsync { get; set; }
public Action<string, object?>? OnSetUnprotectedAsync { get; set; }

public Task<StorageResult<T>> GetAsync<T>(string key)
{
return Task.FromResult(new StorageResult<T>(Success: false, Value: default));
}

public Task<StorageResult<T>> GetUnprotectedAsync<T>(string key)
{
if (OnGetUnprotectedAsync is { } callback)
{
var (success, value) = callback(key);
return Task.FromResult(new StorageResult<T>(Success: success, Value: (T)(value ?? default(T))!));
}
return Task.FromResult(new StorageResult<T>(Success: false, Value: default));
}

Expand All @@ -24,6 +32,10 @@ public Task SetAsync<T>(string key, T value)

public Task SetUnprotectedAsync<T>(string key, T value)
{
if (OnSetUnprotectedAsync is { } callback)
{
callback(key, value);
}
return Task.CompletedTask;
}
}