diff --git a/src/Aspire.Dashboard/Components/Controls/GridValue.razor b/src/Aspire.Dashboard/Components/Controls/GridValue.razor index e90ae7f4344..0dc1a204396 100644 --- a/src/Aspire.Dashboard/Components/Controls/GridValue.razor +++ b/src/Aspire.Dashboard/Components/Controls/GridValue.razor @@ -87,7 +87,8 @@ IconEnd="@(IsMasked ? _unmaskIcon : _maskIcon)" Title="@(IsMasked ? Loc[nameof(ControlsStrings.GridValueMaskShowValue)] : Loc[nameof(ControlsStrings.GridValueMaskHideValue)])" OnClick="ToggleMaskStateAsync" - aria-label="@(IsMasked ? Loc[nameof(ControlsStrings.GridValueMaskShowValue)] : Loc[nameof(ControlsStrings.GridValueMaskHideValue)])" /> + aria-label="@(IsMasked ? Loc[nameof(ControlsStrings.GridValueMaskShowValue)] : Loc[nameof(ControlsStrings.GridValueMaskHideValue)])" + Class="grid-value-mask-button" /> } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor b/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor index 8a23561b2cb..bc7be51e04b 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor +++ b/src/Aspire.Dashboard/Components/Controls/PropertyGrid.razor @@ -11,7 +11,8 @@ Style="width:100%" GenerateHeader="@GenerateHeader" GridTemplateColumns="@GridTemplateColumns" - ShowHover="true"> + ShowHover="true" + Class="@Class"> where TItem : IPropertyGridItem [Parameter] public GenerateHeaderOption GenerateHeader { get; set; } = GenerateHeaderOption.Sticky; + [Parameter] + public string? Class { get; set; } + // Return null if empty so GridValue knows there is no template. private RenderFragment? GetContentAfterValue(TItem context) => ContentAfterValue == s_emptyChildContent ? null diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor index c43801ffbb3..de916bdc3c0 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor @@ -25,13 +25,14 @@ slot="end"/> } - + GridTemplateColumns="1fr 1.5fr" + Class="env-var-properties" /> diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index e6e9408abe7..f3452aad6d1 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Aspire.Dashboard.Model; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Components; @@ -23,28 +24,29 @@ public partial class ResourceDetails private bool _showAll; private ResourceViewModel? _resource; + private readonly HashSet _unmaskedItemNames = new(); - private IQueryable FilteredEnvironmentVariables => + internal IQueryable FilteredEnvironmentVariables => Resource.Environment .Where(vm => (_showAll || vm.FromSpec) && ((IPropertyGridItem)vm).MatchesFilter(_filter)) .AsQueryable(); - private IQueryable FilteredEndpoints => + internal IQueryable FilteredEndpoints => GetEndpoints() .Where(vm => vm.MatchesFilter(_filter)) .AsQueryable(); - private IQueryable FilteredVolumes => + internal IQueryable FilteredVolumes => Resource.Volumes .Where(vm => vm.MatchesFilter(_filter)) .AsQueryable(); - private IQueryable FilteredHealthReports => + internal IQueryable FilteredHealthReports => Resource.HealthReports .Where(vm => vm.MatchesFilter(_filter)) .AsQueryable(); - private IQueryable FilteredResourceProperties => + internal IQueryable FilteredResourceProperties => GetResourceProperties(ordered: true) .Where(vm => (_showAll || vm.KnownProperty != null) && vm.MatchesFilter(_filter)) .AsQueryable(); @@ -55,7 +57,13 @@ public partial class ResourceDetails private bool _isHealthChecksExpanded; private string _filter = ""; - private bool _isMaskAllChecked = true; + private bool? _isMaskAllChecked; + + private bool IsMaskAllChecked + { + get => _isMaskAllChecked ?? false; + set { _isMaskAllChecked = value; } + } private readonly GridSort _endpointValueSort = GridSort.ByAscending(vm => vm.Url ?? vm.Text); @@ -63,6 +71,13 @@ protected override void OnParametersSet() { if (!ReferenceEquals(Resource, _resource)) { + // Reset masking when the resource changes. + if (!string.Equals(Resource.Name, _resource?.Name, StringComparisons.ResourceName)) + { + _isMaskAllChecked = true; + _unmaskedItemNames.Clear(); + } + _resource = Resource; // Collapse details sections when they have no data. @@ -73,7 +88,14 @@ protected override void OnParametersSet() foreach (var item in SensitiveGridItems) { - item.IsValueMasked = _isMaskAllChecked; + if (_isMaskAllChecked != null) + { + item.IsValueMasked = _isMaskAllChecked.Value; + } + else if (_unmaskedItemNames.Count > 0) + { + item.IsValueMasked = !_unmaskedItemNames.Contains(item.Name); + } } } } @@ -95,25 +117,37 @@ private IEnumerable GetResourceProperties(bool ordere private void OnMaskAllCheckedChanged() { + Debug.Assert(_isMaskAllChecked != null); + + _unmaskedItemNames.Clear(); + foreach (var vm in SensitiveGridItems) { - vm.IsValueMasked = _isMaskAllChecked; + vm.IsValueMasked = _isMaskAllChecked.Value; } } - private void OnValueMaskedChanged() + private void OnValueMaskedChanged(IPropertyGridItem vm) { // Check the "Mask All" checkbox if all sensitive values are masked. - - foreach (var item in SensitiveGridItems) + var valueMaskedValues = SensitiveGridItems.Select(i => i.IsValueMasked).Distinct().ToList(); + if (valueMaskedValues.Count == 1) + { + _isMaskAllChecked = valueMaskedValues[0]; + _unmaskedItemNames.Clear(); + } + else { - if (!item.IsValueMasked) + _isMaskAllChecked = null; + + if (vm.IsValueMasked) { - _isMaskAllChecked = false; - return; + _unmaskedItemNames.Remove(vm.Name); + } + else + { + _unmaskedItemNames.Add(vm.Name); } } - - _isMaskAllChecked = true; } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs new file mode 100644 index 00000000000..8f6a61b6cbc --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -0,0 +1,349 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Tests.Shared.DashboardModel; +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +[UseCulture("en-US")] +public class ResourceDetailsTests : TestContext +{ + [Fact] + public async Task ClickMaskAllSwitch_UpdatedResource_MaskChanged() + { + // Arrange + ResourceSetupHelpers.SetupResourceDetails(this); + + var resource1 = ModelTestHelpers.CreateResource( + "app1", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true) + }.ToImmutableArray()); + + // Act + var cut = RenderComponent(builder => + { + builder.Add(p => p.ShowSpecOnlyToggle, true); + builder.Add(p => p.Resource, resource1); + }); + + // Assert + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }); + + var maskAllSwitch = cut.Find(".mask-all-switch"); + await maskAllSwitch.ClickAsync(new MouseEventArgs()); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.False(e.IsValueMasked); + }); + + var resource2 = ModelTestHelpers.CreateResource( + "app1", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) + }.ToImmutableArray()); + + cut.SetParametersAndRender(builder => + { + builder.Add(p => p.Resource, resource2); + }); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar3", e.Name); + Assert.False(e.IsValueMasked); + }); + } + + [Fact] + public async Task ClickMaskAllSwitch_NewResource_MaskChanged() + { + // Arrange + ResourceSetupHelpers.SetupResourceDetails(this); + + var resource1 = ModelTestHelpers.CreateResource( + "app1", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true) + }.ToImmutableArray()); + + // Act + var cut = RenderComponent(builder => + { + builder.Add(p => p.ShowSpecOnlyToggle, true); + builder.Add(p => p.Resource, resource1); + }); + + // Assert + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }); + + var maskAllSwitch = cut.Find(".mask-all-switch"); + await maskAllSwitch.ClickAsync(new MouseEventArgs()); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.False(e.IsValueMasked); + }); + + var resource2 = ModelTestHelpers.CreateResource( + "app2", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) + }.ToImmutableArray()); + + cut.SetParametersAndRender(builder => + { + builder.Add(p => p.Resource, resource2); + }); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar3", e.Name); + Assert.True(e.IsValueMasked); + }); + } + + [Fact] + public async Task ClickMaskEnvVarSwitch_UpdatedResource_MaskChanged() + { + // Arrange + ResourceSetupHelpers.SetupResourceDetails(this); + + var resource1 = ModelTestHelpers.CreateResource( + "app1", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true) + }.ToImmutableArray()); + + // Act + var cut = RenderComponent(builder => + { + builder.Add(p => p.ShowSpecOnlyToggle, true); + builder.Add(p => p.Resource, resource1); + }); + + // Assert + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }); + + var maskValueButton = cut.Find(".env-var-properties .grid-value-mask-button"); + await maskValueButton.ClickAsync(new MouseEventArgs()); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.True(e.IsValueMasked); + }); + + var resource2 = ModelTestHelpers.CreateResource( + "app1", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) + }.ToImmutableArray()); + + cut.SetParametersAndRender(builder => + { + builder.Add(p => p.Resource, resource2); + }); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar3", e.Name); + Assert.True(e.IsValueMasked); + }); + } + + [Fact] + public async Task ClickMaskEnvVarSwitch_NewResource_MaskChanged() + { + // Arrange + ResourceSetupHelpers.SetupResourceDetails(this); + + var resource1 = ModelTestHelpers.CreateResource( + "app1", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true) + }.ToImmutableArray()); + + // Act + var cut = RenderComponent(builder => + { + builder.Add(p => p.ShowSpecOnlyToggle, true); + builder.Add(p => p.Resource, resource1); + }); + + // Assert + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.Equal("value!", e.Value); + Assert.True(e.IsValueMasked); + }); + + var maskValueButton = cut.Find(".env-var-properties .grid-value-mask-button"); + await maskValueButton.ClickAsync(new MouseEventArgs()); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.False(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.True(e.IsValueMasked); + }); + + var resource2 = ModelTestHelpers.CreateResource( + "app2", + environment: new List + { + new EnvironmentVariableViewModel("envvar1", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar2", "value!", fromSpec: true), + new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) + }.ToImmutableArray()); + + cut.SetParametersAndRender(builder => + { + builder.Add(p => p.Resource, resource2); + }); + + Assert.Collection(cut.Instance.FilteredEnvironmentVariables, + e => + { + Assert.Equal("envvar1", e.Name); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar2", e.Name); + Assert.True(e.IsValueMasked); + }, + e => + { + Assert.Equal("envvar3", e.Name); + Assert.True(e.IsValueMasked); + }); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs new file mode 100644 index 00000000000..c3ac9b8f4d9 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs @@ -0,0 +1,48 @@ +// 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; +using Aspire.Dashboard.Otlp.Storage; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal static class ResourceSetupHelpers +{ + public static void SetupResourceDetails(TestContext context) + { + context.Services.AddLocalization(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + + var version = typeof(FluentMain).Assembly.GetName().Version!; + + var dividerModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Divider/FluentDivider.razor.js", version)); + dividerModule.SetupVoid("setDividerAriaOrientation"); + + var searchModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Search/FluentSearch.razor.js", version)); + searchModule.SetupVoid("addAriaHidden", _ => true); + + var anchorModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Anchor/FluentAnchor.razor.js", version)); + + var anchoredRegionModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js", version)); + + var dataGridModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DataGrid/FluentDataGrid.razor.js", version)); + var dataGridRef = dataGridModule.SetupModule("init", _ => true); + dataGridRef.SetupVoid("stop"); + + var keycodeModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/KeyCode/FluentKeyCode.razor.js", version)); + keycodeModule.Setup("RegisterKeyCode", _ => true); + } + + private static string GetFluentFile(string filePath, Version version) + { + return $"{filePath}?v={version}"; + } +} diff --git a/tests/Shared/DashboardModel/ModelTestHelpers.cs b/tests/Shared/DashboardModel/ModelTestHelpers.cs index 2fb167dbd10..5e6e9c36ca8 100644 --- a/tests/Shared/DashboardModel/ModelTestHelpers.cs +++ b/tests/Shared/DashboardModel/ModelTestHelpers.cs @@ -16,6 +16,7 @@ public static ResourceViewModel CreateResource( string? displayName = null, ImmutableArray? urls = null, Dictionary? properties = null, + ImmutableArray? environment = null, string? resourceType = null, string? stateStyle = null, HealthStatus? reportHealthStatus = null, @@ -30,7 +31,7 @@ public static ResourceViewModel CreateResource( CreationTimeStamp = DateTime.UtcNow, StartTimeStamp = DateTime.UtcNow, StopTimeStamp = DateTime.UtcNow, - Environment = [], + Environment = environment ?? [], Urls = urls ?? [], Volumes = [], Properties = properties?.ToFrozenDictionary() ?? FrozenDictionary.Empty,