diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceFilters.razor b/src/Aspire.Dashboard/Components/Controls/ResourceFilters.razor new file mode 100644 index 0000000000..194289bba3 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/ResourceFilters.razor @@ -0,0 +1,48 @@ +@using System.Collections.Concurrent + +@inject IStringLocalizer Loc + + +
+
@Loc[nameof(Resources.Resources.ResourcesResourceTypesHeader)]
+ +
+
+
@Loc[nameof(Resources.Resources.ResourcesResourceStatesHeader)]
+ +
+
+
@Loc[nameof(Resources.Resources.ResourcesDetailsHealthStateProperty)]
+ +
+
+ +@code { + + [Parameter, EditorRequired] + public required ConcurrentDictionary ResourceTypes { get; set; } + + [Parameter, EditorRequired] + public required ConcurrentDictionary ResourceStates { get; set; } + + [Parameter, EditorRequired] + public required ConcurrentDictionary ResourceHealthStates { get; set; } + + [Parameter, EditorRequired] + public required Func OnAllFilterVisibilityCheckedChangedAsync { get; set; } + + [Parameter, EditorRequired] + public required Func OnResourceFilterVisibilityChangedAsync { get; set; } +} diff --git a/src/Aspire.Dashboard/Components/Controls/SelectResourceOptions.razor b/src/Aspire.Dashboard/Components/Controls/SelectResourceOptions.razor new file mode 100644 index 0000000000..29021d83ae --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/SelectResourceOptions.razor @@ -0,0 +1,42 @@ +@namespace Aspire.Dashboard.Components + +@using System.Collections.Concurrent +@using Aspire.Dashboard.Resources + +@inject IStringLocalizer ControlsStringsLoc +@inject IStringLocalizer Loc + +@typeparam TValue where TValue : notnull + + + + + @foreach (var (key, isChecked) in Values.OrderBy(pair => pair.Key.ToString(), StringComparer.OrdinalIgnoreCase)) + { + var label = string.IsNullOrEmpty(key.ToString()) ? Loc[nameof(Resources.ResourceFilterOptionEmpty)] : key.ToString(); + + + } + + +@code { + [Parameter, EditorRequired] + public required ConcurrentDictionary Values { get; set; } + + [Parameter, EditorRequired] + public required Func OnAllValuesCheckedChangedAsync { get; set; } + + [Parameter, EditorRequired] + public required Func OnValueVisibilityChangedAsync { get; set; } + + [Parameter] + public string? Id { get; set; } +} diff --git a/src/Aspire.Dashboard/Components/Controls/SelectResourceOptions.razor.cs b/src/Aspire.Dashboard/Components/Controls/SelectResourceOptions.razor.cs new file mode 100644 index 0000000000..4baf3b2169 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/SelectResourceOptions.razor.cs @@ -0,0 +1,69 @@ +// 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.Concurrent; + +namespace Aspire.Dashboard.Components; + +public partial class SelectResourceOptions +{ + private async Task OnAllValuesCheckedChangedInternalAsync(bool? newAreAllVisible) + { + SetCheckState(newAreAllVisible, Values); + await OnAllValuesCheckedChangedAsync(); + } + + private Task OnValueVisibilityChangedInternalAsync(TValue value, bool isVisible) + { + Values[value] = isVisible; + return OnValueVisibilityChangedAsync(value, isVisible); + } + + private static void SetCheckState(bool? newAreAllVisible, ConcurrentDictionary values) + { + if (newAreAllVisible is null) + { + return; + } + + foreach (var key in values.Keys) + { + values[key] = newAreAllVisible.Value; + } + } + + private static bool? GetCheckState(ConcurrentDictionary values) + { + if (values.IsEmpty) + { + return true; + } + + var areAllChecked = true; + var areAllUnchecked = true; + + foreach (var value in values.Values) + { + if (value) + { + areAllUnchecked = false; + } + else + { + areAllChecked = false; + } + } + + if (areAllChecked) + { + return true; + } + + if (areAllUnchecked) + { + return false; + } + + return null; + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/SelectResourceTypes.razor b/src/Aspire.Dashboard/Components/Controls/SelectResourceTypes.razor deleted file mode 100644 index 1ba88f3e1e..0000000000 --- a/src/Aspire.Dashboard/Components/Controls/SelectResourceTypes.razor +++ /dev/null @@ -1,39 +0,0 @@ -@namespace Aspire.Dashboard.Components - -@using System.Collections.Concurrent -@using Aspire.Dashboard.Resources - -@inject IStringLocalizer ControlsStringsLoc - - - - @foreach (var (resourceType, _) in AllResourceTypes) - { - var isChecked = VisibleResourceTypes.ContainsKey(resourceType); - - } - -@code { - [Parameter, EditorRequired] - public required ConcurrentDictionary AllResourceTypes { get; set; } - - [Parameter, EditorRequired] - public required Func AreAllTypesVisible { get; set; } - - [Parameter, EditorRequired] - public required ConcurrentDictionary VisibleResourceTypes { get; set; } - - [Parameter, EditorRequired] - public required Func OnAllResourceTypesCheckedChangedAsync { get; set; } - - [Parameter, EditorRequired] - public required Func OnResourceTypeVisibilityChangedAsync { get; set; } -} diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index 491af2b917..50c916c4ec 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -5,7 +5,6 @@ @using System.Globalization @using Aspire.Dashboard.Components.Controls.Grid @using Aspire.Dashboard.Model -@using Humanizer @inject IStringLocalizer Loc @inject IStringLocalizer ControlsStringsLoc @inject IStringLocalizer ColumnsLoc @@ -33,36 +32,39 @@ @if (ViewportInformation.IsDesktop) { - + @onclick="() => _isFilterPopupVisible = !_isFilterPopupVisible" + Title="@(NoFiltersSet ? Loc[nameof(Dashboard.Resources.Resources.ResourcesNotFiltered)] : Loc[nameof(Dashboard.Resources.Resources.ResourcesFiltered)])" + aria-label="@(NoFiltersSet ? Loc[nameof(Dashboard.Resources.Resources.ResourcesNotFiltered)] : Loc[nameof(Dashboard.Resources.Resources.ResourcesFiltered)])" /> } else {
-
@Loc[nameof(Dashboard.Resources.Resources.ResourcesResourceTypesHeader)]
- - +
} - -
@Loc[nameof(Dashboard.Resources.Resources.ResourcesResourceTypesHeader)]
+ - + diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 6a06139d0a..90e7cd8170 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Utils; +using Humanizer; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; @@ -49,6 +50,14 @@ public partial class Resources : ComponentBase, IAsyncDisposable [SupplyParameterFromQuery] public string? VisibleTypes { get; set; } + [Parameter] + [SupplyParameterFromQuery] + public string? VisibleStates { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? VisibleHealthStates { get; set; } + [Parameter] [SupplyParameterFromQuery(Name = "resource")] public string? ResourceName { get; set; } @@ -57,11 +66,9 @@ public partial class Resources : ComponentBase, IAsyncDisposable private readonly CancellationTokenSource _watchTaskCancellationTokenSource = new(); private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); - private readonly ConcurrentDictionary _allResourceTypes = []; - private readonly ConcurrentDictionary _visibleResourceTypes = new(StringComparers.ResourceName); private readonly HashSet _expandedResourceNames = []; private string _filter = ""; - private bool _isTypeFilterVisible; + private bool _isFilterPopupVisible; private Task? _resourceSubscriptionTask; private bool _isLoading = true; private string? _elementIdBeforeDetailsViewOpened; @@ -72,80 +79,47 @@ public partial class Resources : ComponentBase, IAsyncDisposable private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; private ColumnSortLabels _sortLabels = ColumnSortLabels.Default; - private bool Filter(ResourceViewModel resource) => _visibleResourceTypes.ContainsKey(resource.ResourceType) && (_filter.Length == 0 || resource.MatchesFilter(_filter)) && !resource.IsHiddenState(); + // Filters in the resource popup + private readonly ConcurrentDictionary _resourceTypesToVisibility = new(StringComparers.ResourceName); + + private readonly ConcurrentDictionary _resourceStatesToVisibility = new(StringComparers.ResourceState); - private async Task OnAllResourceTypesCheckedChangedAsync(bool? areAllTypesVisible) + private readonly ConcurrentDictionary _resourceHealthStatusesToVisibility = new(StringComparer.Ordinal); + + private bool Filter(ResourceViewModel resource) { - AreAllTypesVisible = areAllTypesVisible; - await _dataGrid.SafeRefreshDataAsync(); + return IsKeyValueTrue(resource.ResourceType, _resourceTypesToVisibility) + && IsKeyValueTrue(resource.State ?? string.Empty, _resourceStatesToVisibility) + && IsKeyValueTrue(resource.HealthStatus?.Humanize() ?? string.Empty, _resourceHealthStatusesToVisibility) + && (_filter.Length == 0 || resource.MatchesFilter(_filter)) + && !resource.IsHiddenState(); + + static bool IsKeyValueTrue(string key, IDictionary dictionary) => dictionary.TryGetValue(key, out var value) && value; } - private async Task OnResourceTypeVisibilityChangedAsync(string resourceType, bool isVisible) + private async Task OnAllFilterVisibilityCheckedChangedAsync() { - if (isVisible) - { - _visibleResourceTypes[resourceType] = true; - } - else - { - _visibleResourceTypes.TryRemove(resourceType, out _); - } - await ClearSelectedResourceAsync(); await _dataGrid.SafeRefreshDataAsync(); } - private async Task HandleSearchFilterChangedAsync() + private async Task OnResourceFilterVisibilityChangedAsync(string resourceType, bool isVisible) { await ClearSelectedResourceAsync(); await _dataGrid.SafeRefreshDataAsync(); } - private bool? AreAllTypesVisible + private async Task HandleSearchFilterChangedAsync() { - get - { - static bool SetEqualsKeys(ConcurrentDictionary left, ConcurrentDictionary right) - { - // PERF: This is inefficient since Keys locks and copies the keys. - var keysLeft = left.Keys; - var keysRight = right.Keys; - - return keysLeft.Count == keysRight.Count && keysLeft.OrderBy(key => key, StringComparers.ResourceType).SequenceEqual(keysRight.OrderBy(key => key, StringComparers.ResourceType), StringComparers.ResourceType); - } - - return SetEqualsKeys(_visibleResourceTypes, _allResourceTypes) - ? true - : _visibleResourceTypes.IsEmpty - ? false - : null; - } - set - { - static bool UnionWithKeys(ConcurrentDictionary left, ConcurrentDictionary right) - { - // .Keys locks and copies the keys so avoid it here. - foreach (var (key, _) in right) - { - left[key] = true; - } - - return true; - } - - if (value is true) - { - UnionWithKeys(_visibleResourceTypes, _allResourceTypes); - } - else if (value is false) - { - _visibleResourceTypes.Clear(); - } - - StateHasChanged(); - } + await ClearSelectedResourceAsync(); + await _dataGrid.SafeRefreshDataAsync(); } + private bool NoFiltersSet => AreAllTypesVisible && AreAllStatesVisible && AreAllHealthStatesVisible; + private bool AreAllTypesVisible => _resourceTypesToVisibility.Values.All(value => value); + private bool AreAllStatesVisible => _resourceStatesToVisibility.Values.All(value => value); + private bool AreAllHealthStatesVisible => _resourceHealthStatusesToVisibility.Values.All(value => value); + private readonly GridSort _nameSort = GridSort.ByAscending(p => p.Resource, ResourceViewModelNameComparer.Instance); private readonly GridSort _stateSort = GridSort.ByAscending(p => p.Resource.State).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance); private readonly GridSort _startTimeSort = GridSort.ByDescending(p => p.Resource.StartTimeStamp).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance); @@ -188,6 +162,8 @@ protected override async Task OnInitializedAsync() async Task SubscribeResourcesAsync() { var preselectedVisibleResourceTypes = VisibleTypes?.Split(',').ToHashSet(); + var preselectedVisibleResourceStates = VisibleStates?.Split(',').ToHashSet(); + var preselectedVisibleResourceHealthStates = VisibleHealthStates?.Split(',').ToHashSet(); var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_watchTaskCancellationTokenSource.Token); @@ -195,13 +171,9 @@ async Task SubscribeResourcesAsync() foreach (var resource in snapshot) { var added = _resourceByName.TryAdd(resource.Name, resource); - - _allResourceTypes.TryAdd(resource.ResourceType, true); - - if (preselectedVisibleResourceTypes is null || preselectedVisibleResourceTypes.Contains(resource.ResourceType)) - { - _visibleResourceTypes.TryAdd(resource.ResourceType, true); - } + _resourceTypesToVisibility.TryAdd(resource.ResourceType, preselectedVisibleResourceTypes is null || preselectedVisibleResourceTypes.Contains(resource.ResourceType)); + _resourceStatesToVisibility.TryAdd(resource.State ?? string.Empty, preselectedVisibleResourceStates is null || preselectedVisibleResourceStates.Contains(resource.State ?? string.Empty)); + _resourceHealthStatusesToVisibility.TryAdd(resource.HealthStatus?.Humanize() ?? string.Empty, preselectedVisibleResourceHealthStates is null || preselectedVisibleResourceHealthStates.Contains(resource.HealthStatus?.Humanize() ?? string.Empty)); Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data."); } @@ -226,13 +198,6 @@ async Task SubscribeResourcesAsync() SelectedResource = resource; selectedResourceHasChanged = true; } - - if (_allResourceTypes.TryAdd(resource.ResourceType, true)) - { - // If someone has filtered out a resource type then don't remove filter because an update was received. - // Only automatically set resource type to visible if it is a new resource. - _visibleResourceTypes[resource.ResourceType] = true; - } } else if (changeType == ResourceViewModelChangeType.Delete) { @@ -257,11 +222,17 @@ await InvokeAsync(async () => } } + internal IEnumerable GetFilteredResources() + { + return _resourceByName + .Values + .Where(Filter); + } + private ValueTask> GetData(GridItemsProviderRequest request) { // Get filtered and ordered resources. - var filteredResources = _resourceByName.Values - .Where(Filter) + var filteredResources = GetFilteredResources() .Select(r => new ResourceGridViewModel { Resource = r }) .AsQueryable(); filteredResources = request.ApplySorting(filteredResources); @@ -276,9 +247,10 @@ private ValueTask> GetData(GridIt // Paging visible resources. var query = orderedResources .Skip(request.StartIndex) - .Take(request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount); + .Take(request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount) + .ToList(); - return ValueTask.FromResult(GridItemsProviderResult.From(query.ToList(), orderedResources.Count)); + return ValueTask.FromResult(GridItemsProviderResult.From(query, orderedResources.Count)); } private void UpdateMaxHighlightedCount() diff --git a/src/Aspire.Dashboard/Resources/Resources.Designer.cs b/src/Aspire.Dashboard/Resources/Resources.Designer.cs index 3627dda67e..66b7509003 100644 --- a/src/Aspire.Dashboard/Resources/Resources.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Resources.Designer.cs @@ -168,6 +168,15 @@ public static string ResourceDetailsViewConsoleLogs { } } + /// + /// Looks up a localized string similar to (Unset). + /// + public static string ResourceFilterOptionEmpty { + get { + return ResourceManager.GetString("ResourceFilterOptionEmpty", resourceCulture); + } + } + /// /// Looks up a localized string similar to Actions. /// @@ -357,6 +366,15 @@ public static string ResourcesEnvironmentVariablesHeader { } } + /// + /// Looks up a localized string similar to Has filters. + /// + public static string ResourcesFiltered { + get { + return ResourceManager.GetString("ResourcesFiltered", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resources. /// @@ -384,6 +402,15 @@ public static string ResourcesNoResources { } } + /// + /// Looks up a localized string similar to No filters. + /// + public static string ResourcesNotFiltered { + get { + return ResourceManager.GetString("ResourcesNotFiltered", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} resources. /// @@ -393,6 +420,15 @@ public static string ResourcesPageTitle { } } + /// + /// Looks up a localized string similar to State. + /// + public static string ResourcesResourceStatesHeader { + get { + return ResourceManager.GetString("ResourcesResourceStatesHeader", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resource types. /// @@ -438,24 +474,6 @@ public static string ResourcesTypeColumnHeader { } } - /// - /// Looks up a localized string similar to Type filter: All types visible. - /// - public static string ResourcesTypeFilterAllVisible { - get { - return ResourceManager.GetString("ResourcesTypeFilterAllVisible", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Type filter: Filtered. - /// - public static string ResourcesTypeFiltered { - get { - return ResourceManager.GetString("ResourcesTypeFiltered", resourceCulture); - } - } - /// /// Looks up a localized string similar to Waiting for health data.... /// diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index 7ecdd3deb7..2160b40790 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -124,15 +124,21 @@ Resources - - Type filter: All types visible + + No filters - - Type filter: Filtered + + Has filters Resource types + + State + + + (Unset) + Environment variables for {0} {0} is a resource diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf index b32fc7513b..266f13fbc8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf @@ -62,6 +62,11 @@ Zobrazit protokoly konzoly + + (Unset) + (Unset) + + Actions Akce @@ -162,6 +167,21 @@ Prostředí + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Zdroj @@ -212,16 +232,6 @@ Typ - - Type filter: All types visible - Filtr typů: Jsou viditelné všechny typy - - - - Type filter: Filtered - Filtr typů: Vyfiltrováno - - Waiting for health data... Čeká se na data o stavu... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf index 11130545b2..e198cc9feb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf @@ -62,6 +62,11 @@ Konsolenprotokolle anzeigen + + (Unset) + (Unset) + + Actions Aktionen @@ -162,6 +167,21 @@ Umgebung + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Quelle @@ -212,16 +232,6 @@ Typ - - Type filter: All types visible - Typfilter: Alle Typen sichtbar - - - - Type filter: Filtered - Typfilter: Gefiltert - - Waiting for health data... Auf Integritätsdaten wird gewartet... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf index 4e61942137..bed939096b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf @@ -62,6 +62,11 @@ Ver registros de consola + + (Unset) + (Unset) + + Actions Acciones @@ -162,6 +167,21 @@ Entorno + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Origen @@ -212,16 +232,6 @@ Tipo - - Type filter: All types visible - Filtro de tipo: todos los tipos visibles - - - - Type filter: Filtered - Filtro de tipo: filtrado - - Waiting for health data... Esperando datos de estado... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf index 67be85e073..0a3beff664 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf @@ -62,6 +62,11 @@ Afficher les journaux de console + + (Unset) + (Unset) + + Actions Actions @@ -162,6 +167,21 @@ Environnement + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Source @@ -212,16 +232,6 @@ Type - - Type filter: All types visible - Filtre de type : tous les types sont visibles - - - - Type filter: Filtered - Filtre de type : filtré - - Waiting for health data... En attente des données d’intégrité... Merci de patienter. diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf index dc8ada6a40..d9c74d2021 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf @@ -62,6 +62,11 @@ Visualizza log della console + + (Unset) + (Unset) + + Actions Azioni @@ -162,6 +167,21 @@ Ambiente + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Origine @@ -212,16 +232,6 @@ Tipo - - Type filter: All types visible - Filtro tipo: tutti i tipi sono visibili - - - - Type filter: Filtered - Filtro tipo: filtro applicato - - Waiting for health data... In attesa dei dati sull'integrità... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf index 161e998e01..c23b989e26 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf @@ -62,6 +62,11 @@ コンソール ログの表示 + + (Unset) + (Unset) + + Actions アクション @@ -162,6 +167,21 @@ 環境 + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source ソース @@ -212,16 +232,6 @@ 種類 - - Type filter: All types visible - 型フィルター: すべての型が表示されます - - - - Type filter: Filtered - 型フィルター: フィルター処理済み - - Waiting for health data... 正常性データを待機しています... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf index aaf648268b..6f02d0ce23 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf @@ -62,6 +62,11 @@ 콘솔 로그 보기 + + (Unset) + (Unset) + + Actions 작업 @@ -162,6 +167,21 @@ 환경 + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source 원본 @@ -212,16 +232,6 @@ 형식 - - Type filter: All types visible - 형식 필터: 모든 형식 표시 - - - - Type filter: Filtered - 형식 필터: 필터링됨 - - Waiting for health data... 상태 데이터를 기다리는 중... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf index e193efb77d..1572c87f1b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf @@ -62,6 +62,11 @@ Wyświetl dzienniki konsoli + + (Unset) + (Unset) + + Actions Akcje @@ -162,6 +167,21 @@ Środowisko + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Źródło @@ -212,16 +232,6 @@ Typ - - Type filter: All types visible - Filtr typu: wszystkie typy są widoczne - - - - Type filter: Filtered - Filtr typu: filtrowany - - Waiting for health data... Trwa oczekiwanie na dane kondycji... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf index b20aaea852..e87c87321d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf @@ -62,6 +62,11 @@ Exibir registros do console + + (Unset) + (Unset) + + Actions Ações @@ -162,6 +167,21 @@ Ambiente + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Origem @@ -212,16 +232,6 @@ Tipo - - Type filter: All types visible - Filtro de tipo: todos os tipos visíveis - - - - Type filter: Filtered - Filtro de tipo: filtrado - - Waiting for health data... Aguardando os dados de integridade... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf index b07b233475..d53b2fb068 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf @@ -62,6 +62,11 @@ Просмотр журналов консоли + + (Unset) + (Unset) + + Actions Действия @@ -162,6 +167,21 @@ Среда + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Источник @@ -212,16 +232,6 @@ Тип - - Type filter: All types visible - Фильтр типов: все типы видимы - - - - Type filter: Filtered - Фильтр типов: отфильтрованный - - Waiting for health data... Ожидание данных о работоспособности... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf index 70142a7691..194bb78a03 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf @@ -62,6 +62,11 @@ Konsol günlüklerini görüntüle + + (Unset) + (Unset) + + Actions Eylemler @@ -162,6 +167,21 @@ Ortam + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source Kaynak @@ -212,16 +232,6 @@ Tür - - Type filter: All types visible - Tür filtresi: Tüm türler görünür - - - - Type filter: Filtered - Tür filtresi: Filtrelendi - - Waiting for health data... Sistem durumu verileri bekleniyor... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf index 50a95557c2..c8f06502cf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf @@ -62,6 +62,11 @@ 查看控制台日志 + + (Unset) + (Unset) + + Actions 操作 @@ -162,6 +167,21 @@ 环境 + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source @@ -212,16 +232,6 @@ 类型 - - Type filter: All types visible - 类型筛选器: 所有类型可见 - - - - Type filter: Filtered - 类型筛选器: 已筛选 - - Waiting for health data... 正在等待运行状况数据... diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf index 374779c317..a6528560b5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf @@ -62,6 +62,11 @@ 檢視主機記錄 + + (Unset) + (Unset) + + Actions 動作 @@ -162,6 +167,21 @@ 環境 + + Has filters + Has filters + + + + No filters + No filters + + + + State + State + + Source 來源 @@ -212,16 +232,6 @@ 類型 - - Type filter: All types visible - 類型篩選: 所有類型都可見 - - - - Type filter: Filtered - 類型篩選: 已篩選 - - Waiting for health data... 正在等待健康情況資料... diff --git a/src/Aspire.Dashboard/wwwroot/css/app.css b/src/Aspire.Dashboard/wwwroot/css/app.css index 8541c6be05..5b1e0ecc04 100644 --- a/src/Aspire.Dashboard/wwwroot/css/app.css +++ b/src/Aspire.Dashboard/wwwroot/css/app.css @@ -739,3 +739,8 @@ fluent-switch.table-switch::part(label) { margin-bottom: 2px; min-width: 240px; } + +.resources-filter-popup { + max-height: 400px; + overflow-y: auto; +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs new file mode 100644 index 0000000000..74fcc54222 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -0,0 +1,117 @@ +// 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.Resize; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Bunit; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Pages; + +[UseCulture("en-US")] +public partial class ResourcesTests : TestContext +{ + [Fact] + public void Test() + { + // Arrange + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesPage( + this, + viewport, + [ + CreateResource( + "Resource1", + "Type1", + "Running", + ImmutableArray.Create(new HealthReportViewModel("Null", null, "Description1", null))), + CreateResource( + "Resource2", + "Type2", + "Running", + ImmutableArray.Create(new HealthReportViewModel("Healthy", HealthStatus.Healthy, "Description2", null))), + CreateResource( + "Resource3", + "Type3", + "Stopping", + ImmutableArray.Create(new HealthReportViewModel("Degraded", HealthStatus.Degraded, "Description3", null))), + ]); + + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + // Open the resource filter + cut.Find("#resourceFilterButton").Click(); + + // Assert 1 (the correct filter options are shown) + AssertResourceFilterListEquals(cut, [ + new("Type1", true), + new("Type2", true), + new("Type3", true), + ], [ + new("Running", true), + new("Stopping", true), + ], [ + new("", true), + new("Healthy", true), + new("Unhealthy", true), + ]); + + // Assert 2 (unselect a resource type, assert that a resource was removed) + cut.FindComponents>().First(f => f.Instance.Id == "resource-states") + .FindComponents() + .First(checkbox => checkbox.Instance.Label == "Stopping") + .Find("fluent-checkbox") + .TriggerEvent("oncheckedchange", new CheckboxChangeEventArgs { Checked = false }); + + // above is triggered asynchronously, so wait for the state to change + cut.WaitForState(() => cut.Instance.GetFilteredResources().Count() == 2); + } + + private static void AssertResourceFilterListEquals(IRenderedComponent cut, IEnumerable> types, IEnumerable> states, IEnumerable> healthStates) + { + var filterComponents = cut.FindComponents>(); + Assert.Equal(3, filterComponents.Count); + + var typeSelect = filterComponents.First(f => f.Instance.Id == "resource-types"); + Assert.Equal(types, typeSelect.Instance.Values.ToImmutableSortedDictionary() /* sort for equality comparison */ ); + + var stateSelect = filterComponents.First(f => f.Instance.Id == "resource-states"); + Assert.Equal(states, stateSelect.Instance.Values.ToImmutableSortedDictionary() /* sort for equality comparison */); + + var healthSelect = filterComponents.First(f => f.Instance.Id == "resource-health-states"); + Assert.Equal(healthStates, healthSelect.Instance.Values.ToImmutableSortedDictionary() /* sort for equality comparison */); + } + + private static ResourceViewModel CreateResource(string name, string type, string? state, ImmutableArray? healthReports) + { + return new ResourceViewModel + { + Name = name, + ResourceType = type, + State = state, + KnownState = state is not null ? Enum.Parse(state) : null, + DisplayName = name, + Uid = name, + HealthReports = healthReports ?? [], + + // unused properties + StateStyle = null, + CreationTimeStamp = null, + StartTimeStamp = null, + StopTimeStamp = null, + Environment = default, + Urls = [], + Volumes = default, + Relationships = default, + Properties = ImmutableDictionary.Empty, + Commands = [], + }; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs index 5918bbac3d..27ce5cda72 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs @@ -1,10 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Threading.Channels; +using Aspire.Dashboard.Components.Pages; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.BrowserStorage; using Aspire.Dashboard.Otlp.Storage; using Bunit; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Microsoft.FluentUI.AspNetCore.Components; namespace Aspire.Dashboard.Components.Tests.Shared; @@ -29,9 +37,8 @@ public static void SetupResourceDetails(TestContext context) 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)); + context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Anchor/FluentAnchor.razor.js", version)); + 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); @@ -43,6 +50,60 @@ public static void SetupResourceDetails(TestContext context) context.JSInterop.SetupVoid("scrollToTop", _ => true); } + public static void SetupResourcesPage(TestContext context, ViewportInformation viewport, IList initialResources) + { + 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 inputLabelModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Label/FluentInputLabel.razor.js", version)); + inputLabelModule.SetupVoid("setInputAriaLabel", _ => true); + + var dataGridModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DataGrid/FluentDataGrid.razor.js", version)); + dataGridModule.SetupModule("init", _ => true); + dataGridModule.SetupVoid("enableColumnResizing", _ => true); + + var searchModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Search/FluentSearch.razor.js", version)); + searchModule.SetupVoid("addAriaHidden", _ => true); + + var keycodeModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/KeyCode/FluentKeyCode.razor.js", version)); + keycodeModule.Setup("RegisterKeyCode", _ => true); + + var checkboxModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Checkbox/FluentCheckbox.razor.js", version)); + checkboxModule.SetupVoid("setFluentCheckBoxIndeterminate", _ => true); + checkboxModule.SetupVoid("stop", _ => true); + + var anchoredRegionModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js", version)); + anchoredRegionModule.SetupVoid("goToNextFocusableElement", _ => true); + + context.Services.AddLocalization(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(Options.Create(new DashboardOptions())); + context.Services.AddSingleton(); + context.Services.AddSingleton>(NullLogger.Instance); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddFluentUIComponents(); + context.Services.AddScoped(); + context.Services.AddSingleton(new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>)); + + var dimensionManager = context.Services.GetRequiredService(); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + // Setting a provider ID on menu service is required to simulate on the page. + // This makes FluentMenu render without error. + var menuService = context.Services.GetRequiredService(); + menuService.ProviderId = "Test"; + } + private static string GetFluentFile(string filePath, Version version) { return $"{filePath}?v={version}";