|
// 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;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.ResourceGraph;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Humanizer;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
namespace Aspire.Dashboard.Components.Pages;
public partial class Resources : ComponentBase, IAsyncDisposable, IPageWithSessionAndUrlState<Resources.ResourcesViewModel, Resources.ResourcesPageState>
{
private const string TypeColumn = nameof(TypeColumn);
private const string NameColumn = nameof(NameColumn);
private const string StateColumn = nameof(StateColumn);
private const string StartTimeColumn = nameof(StartTimeColumn);
private const string SourceColumn = nameof(SourceColumn);
private const string EndpointsColumn = nameof(EndpointsColumn);
private const string ActionsColumn = nameof(ActionsColumn);
private Subscription? _logsSubscription;
private IList<GridColumn>? _gridColumns;
private Dictionary<ApplicationKey, int>? _applicationUnviewedErrorCounts;
[Inject]
public required IDashboardClient DashboardClient { get; init; }
[Inject]
public required TelemetryRepository TelemetryRepository { get; init; }
[Inject]
public required NavigationManager NavigationManager { get; init; }
[Inject]
public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }
[Inject]
public required BrowserTimeProvider TimeProvider { get; init; }
[Inject]
public required IJSRuntime JS { get; init; }
[Inject]
public required ISessionStorage SessionStorage { get; init; }
public string BasePath => DashboardUrls.ResourcesBasePath;
public string SessionStorageKey => "Resources_PageState";
public ResourcesViewModel PageViewModel { get; set; } = null!;
[Parameter]
[SupplyParameterFromQuery(Name = "view")]
public string? ViewKindName { get; set; }
[CascadingParameter]
public required ViewportInformation ViewportInformation { get; set; }
[Parameter]
[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; }
private ResourceViewModel? SelectedResource { get; set; }
private readonly CancellationTokenSource _watchTaskCancellationTokenSource = new();
private readonly ConcurrentDictionary<string, ResourceViewModel> _resourceByName = new(StringComparers.ResourceName);
private readonly HashSet<string> _collapsedResourceNames = new(StringComparers.ResourceName);
private string _filter = "";
private bool _isFilterPopupVisible;
private Task? _resourceSubscriptionTask;
private bool _isLoading = true;
private string? _elementIdBeforeDetailsViewOpened;
private FluentDataGrid<ResourceGridViewModel> _dataGrid = null!;
private GridColumnManager _manager = null!;
private int _maxHighlightedCount;
private readonly List<MenuButtonItem> _resourcesMenuItems = new();
private DotNetObjectReference<ResourcesInterop>? _resourcesInteropReference;
private IJSObjectReference? _jsModule;
private AspirePageContentLayout? _contentLayout;
private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default;
private ColumnSortLabels _sortLabels = ColumnSortLabels.Default;
// Filters in the resource popup
// Internal for tests
internal ConcurrentDictionary<string, bool> ResourceTypesToVisibility { get; } = new(StringComparers.ResourceName);
internal ConcurrentDictionary<string, bool> ResourceStatesToVisibility { get; } = new(StringComparers.ResourceState);
internal ConcurrentDictionary<string, bool> ResourceHealthStatusesToVisibility { get; } = new(StringComparer.Ordinal);
private bool Filter(ResourceViewModel resource)
{
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<string, bool> dictionary) => dictionary.TryGetValue(key, out var value) && value;
}
private async Task OnAllFilterVisibilityCheckedChangedAsync()
{
await ClearSelectedResourceAsync();
await _dataGrid.SafeRefreshDataAsync();
UpdateMenuButtons();
}
private async Task OnResourceFilterVisibilityChangedAsync(string resourceType, bool isVisible)
{
await UpdateResourceGraphResourcesAsync();
await ClearSelectedResourceAsync();
await _dataGrid.SafeRefreshDataAsync();
UpdateMenuButtons();
}
private async Task HandleSearchFilterChangedAsync()
{
await UpdateResourceGraphResourcesAsync();
await ClearSelectedResourceAsync();
await _dataGrid.SafeRefreshDataAsync();
}
// Internal for tests
internal bool NoFiltersSet => AreAllTypesVisible && AreAllStatesVisible && AreAllHealthStatesVisible;
internal bool AreAllTypesVisible => ResourceTypesToVisibility.Values.All(value => value);
internal bool AreAllStatesVisible => ResourceStatesToVisibility.Values.All(value => value);
internal bool AreAllHealthStatesVisible => ResourceHealthStatusesToVisibility.Values.All(value => value);
private readonly GridSort<ResourceGridViewModel> _nameSort = GridSort<ResourceGridViewModel>.ByAscending(p => p.Resource, ResourceViewModelNameComparer.Instance);
private readonly GridSort<ResourceGridViewModel> _stateSort = GridSort<ResourceGridViewModel>.ByAscending(p => p.Resource.State).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance);
private readonly GridSort<ResourceGridViewModel> _startTimeSort = GridSort<ResourceGridViewModel>.ByDescending(p => p.Resource.StartTimeStamp).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance);
private readonly GridSort<ResourceGridViewModel> _typeSort = GridSort<ResourceGridViewModel>.ByAscending(p => p.Resource.ResourceType).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance);
protected override async Task OnInitializedAsync()
{
(_resizeLabels, _sortLabels) = DashboardUIHelpers.CreateGridLabels(ControlsStringsLoc);
_gridColumns = [
new GridColumn(Name: NameColumn, DesktopWidth: "1.5fr", MobileWidth: "1.5fr"),
new GridColumn(Name: StateColumn, DesktopWidth: "1.25fr", MobileWidth: "1.25fr"),
new GridColumn(Name: StartTimeColumn, DesktopWidth: "1fr"),
new GridColumn(Name: TypeColumn, DesktopWidth: "1fr"),
new GridColumn(Name: SourceColumn, DesktopWidth: "2.25fr"),
new GridColumn(Name: EndpointsColumn, DesktopWidth: "2.25fr", MobileWidth: "2fr"),
new GridColumn(Name: ActionsColumn, DesktopWidth: "minmax(150px, 1.5fr)", MobileWidth: "1fr")
];
PageViewModel = new ResourcesViewModel
{
SelectedViewKind = ResourceViewKind.Table
};
_applicationUnviewedErrorCounts = TelemetryRepository.GetApplicationUnviewedErrorLogsCount();
UpdateMenuButtons();
if (DashboardClient.IsEnabled)
{
var collapsedResult = await SessionStorage.GetAsync<List<string>>(BrowserStorageKeys.ResourcesCollapsedResourceNames);
if (collapsedResult.Success)
{
foreach (var resourceName in collapsedResult.Value)
{
_collapsedResourceNames.Add(resourceName);
}
}
await SubscribeResourcesAsync();
}
_logsSubscription = TelemetryRepository.OnNewLogs(null, SubscriptionType.Other, async () =>
{
var newApplicationUnviewedErrorCounts = TelemetryRepository.GetApplicationUnviewedErrorLogsCount();
// Only update UI if the error counts have changed.
if (ApplicationErrorCountsChanged(newApplicationUnviewedErrorCounts))
{
_applicationUnviewedErrorCounts = newApplicationUnviewedErrorCounts;
await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
}
});
_isLoading = false;
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);
// Apply snapshot.
foreach (var resource in snapshot)
{
var added = UpdateFromResource(
resource,
type => preselectedVisibleResourceTypes is null || preselectedVisibleResourceTypes.Contains(type),
state => preselectedVisibleResourceStates is null || preselectedVisibleResourceStates.Contains(state),
healthStatus => preselectedVisibleResourceHealthStates is null || preselectedVisibleResourceHealthStates.Contains(healthStatus));
Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data.");
}
UpdateMaxHighlightedCount();
await _dataGrid.SafeRefreshDataAsync();
// Listen for updates and apply.
_resourceSubscriptionTask = Task.Run(async () =>
{
await foreach (var changes in subscription.WithCancellation(_watchTaskCancellationTokenSource.Token).ConfigureAwait(false))
{
var selectedResourceHasChanged = false;
foreach (var (changeType, resource) in changes)
{
if (changeType == ResourceViewModelChangeType.Upsert)
{
UpdateFromResource(
resource,
t => AreAllTypesVisible,
s => AreAllStatesVisible,
s => AreAllHealthStatesVisible);
if (string.Equals(SelectedResource?.Name, resource.Name, StringComparisons.ResourceName))
{
SelectedResource = resource;
selectedResourceHasChanged = true;
}
}
else if (changeType == ResourceViewModelChangeType.Delete)
{
var removed = _resourceByName.TryRemove(resource.Name, out _);
Debug.Assert(removed, "Cannot remove unknown resource.");
}
}
UpdateMaxHighlightedCount();
await UpdateResourceGraphResourcesAsync();
await InvokeAsync(async () =>
{
await _dataGrid.SafeRefreshDataAsync();
if (selectedResourceHasChanged)
{
// Notify page that the selected resource parameter has changed.
// This is required so the resource open in the details view is refreshed.
StateHasChanged();
}
});
}
});
}
bool UpdateFromResource(ResourceViewModel resource, Func<string, bool> resourceTypeVisible, Func<string, bool> stateVisible, Func<string, bool> healthStatusVisible)
{
// This is ok from threadsafty perspective because we are the only thread that's modifying resources.
bool added;
if (_resourceByName.TryGetValue(resource.Name, out _))
{
added = false;
_resourceByName[resource.Name] = resource;
}
else
{
added = _resourceByName.TryAdd(resource.Name, resource);
}
ResourceTypesToVisibility.TryAdd(resource.ResourceType, resourceTypeVisible(resource.ResourceType));
ResourceStatesToVisibility.TryAdd(resource.State ?? string.Empty, stateVisible(resource.State ?? string.Empty));
ResourceHealthStatusesToVisibility.TryAdd(resource.HealthStatus?.Humanize() ?? string.Empty, healthStatusVisible(resource.HealthStatus?.Humanize() ?? string.Empty));
UpdateMenuButtons();
return added;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph && _jsModule == null)
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "/js/app-resourcegraph.js");
_resourcesInteropReference = DotNetObjectReference.Create(new ResourcesInterop(this));
await _jsModule.InvokeVoidAsync("initializeResourcesGraph", _resourcesInteropReference);
await UpdateResourceGraphResourcesAsync();
}
}
private async Task UpdateResourceGraphResourcesAsync()
{
if (PageViewModel.SelectedViewKind != ResourceViewKind.Graph || _jsModule == null)
{
return;
}
var activeResources = _resourceByName.Values.Where(Filter).OrderBy(e => e.ResourceType).ThenBy(e => e.Name).ToList();
var resources = activeResources.Select(r => ResourceGraphMapper.MapResource(r, _resourceByName, ColumnsLoc)).ToList();
await _jsModule.InvokeVoidAsync("updateResourcesGraph", resources);
}
private class ResourcesInterop(Resources resources)
{
[JSInvokable]
public async Task SelectResource(string id)
{
if (resources._resourceByName.TryGetValue(id, out var resource))
{
await resources.InvokeAsync(async () =>
{
await resources.ShowResourceDetailsAsync(resource, null!);
resources.StateHasChanged();
});
}
}
}
internal IEnumerable<ResourceViewModel> GetFilteredResources()
{
return _resourceByName
.Values
.Where(Filter);
}
private ValueTask<GridItemsProviderResult<ResourceGridViewModel>> GetData(GridItemsProviderRequest<ResourceGridViewModel> request)
{
// Get filtered and ordered resources.
var filteredResources = GetFilteredResources()
.Select(r => new ResourceGridViewModel { Resource = r })
.AsQueryable();
filteredResources = request.ApplySorting(filteredResources);
// Rearrange resources based on parent information.
// This must happen after resources are ordered so nested resources are in the right order.
// Collapsed resources are filtered out of results.
var orderedResources = ResourceGridViewModel.OrderNestedResources(filteredResources.ToList(), r => _collapsedResourceNames.Contains(r.Name))
.Where(r => !r.IsHidden)
.ToList();
// Paging visible resources.
var query = orderedResources
.Skip(request.StartIndex)
.Take(request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount)
.ToList();
return ValueTask.FromResult(GridItemsProviderResult.From(query, orderedResources.Count));
}
private void UpdateMenuButtons()
{
_resourcesMenuItems.Clear();
if (HasCollapsedResources())
{
_resourcesMenuItems.Add(new MenuButtonItem
{
IsDisabled = false,
OnClick = OnToggleCollapseAll,
Text = Loc[nameof(Dashboard.Resources.Resources.ResourceExpandAllChildren)],
Icon = new Icons.Regular.Size16.Eye()
});
}
else
{
_resourcesMenuItems.Add(new MenuButtonItem
{
IsDisabled = false,
OnClick = OnToggleCollapseAll,
Text = Loc[nameof(Dashboard.Resources.Resources.ResourceCollapseAllChildren)],
Icon = new Icons.Regular.Size16.EyeOff()
});
}
}
private bool HasCollapsedResources()
{
return _resourceByName.Any(r => !r.Value.IsHiddenState() && _collapsedResourceNames.Contains(r.Key));
}
private void UpdateMaxHighlightedCount()
{
var maxHighlightedCount = 0;
foreach (var kvp in _resourceByName)
{
var resourceHighlightedCount = 0;
foreach (var command in kvp.Value.Commands)
{
if (command.IsHighlighted && command.State != CommandViewModelState.Hidden)
{
resourceHighlightedCount++;
}
}
maxHighlightedCount = Math.Max(maxHighlightedCount, resourceHighlightedCount);
}
// Don't attempt to display more than 2 highlighted commands. Many commands will take up too much space.
// Extra highlighted commands are still available in the menu.
_maxHighlightedCount = Math.Min(maxHighlightedCount, DashboardUIHelpers.MaxHighlightedCommands);
}
protected override async Task OnParametersSetAsync()
{
if (await this.InitializeViewModelAsync())
{
return;
}
if (ResourceName is not null)
{
if (_resourceByName.TryGetValue(ResourceName, out var selectedResource))
{
await ShowResourceDetailsAsync(selectedResource, buttonId: null);
if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph)
{
await UpdateResourceGraphSelectedAsync();
}
}
// Navigate to remove ?resource=xxx in the URL.
NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(), new NavigationOptions { ReplaceHistoryEntry = true });
}
}
private bool ApplicationErrorCountsChanged(Dictionary<ApplicationKey, int> newApplicationUnviewedErrorCounts)
{
if (_applicationUnviewedErrorCounts == null || _applicationUnviewedErrorCounts.Count != newApplicationUnviewedErrorCounts.Count)
{
return true;
}
foreach (var (application, count) in newApplicationUnviewedErrorCounts)
{
if (!_applicationUnviewedErrorCounts.TryGetValue(application, out var oldCount) || oldCount != count)
{
return true;
}
}
return false;
}
private async Task ShowResourceDetailsAsync(ResourceViewModel resource, string? buttonId)
{
_elementIdBeforeDetailsViewOpened = buttonId;
if (string.Equals(SelectedResource?.Name, resource.Name, StringComparisons.ResourceName))
{
await ClearSelectedResourceAsync();
}
else
{
SelectedResource = resource;
// Ensure that the selected resource is visible in the grid. All parents must be expanded.
var current = resource;
while (current != null)
{
if (current.GetResourcePropertyValue(KnownProperties.Resource.ParentName) is { Length: > 0 } value)
{
if (_resourceByName.TryGetValue(value, out current))
{
_collapsedResourceNames.Remove(value);
continue;
}
}
break;
}
await _dataGrid.SafeRefreshDataAsync();
}
}
private async Task ClearSelectedResourceAsync(bool causedByUserAction = false)
{
SelectedResource = null;
await InvokeAsync(StateHasChanged);
if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph)
{
await UpdateResourceGraphSelectedAsync();
}
if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
{
await JS.InvokeVoidAsync("focusElement", _elementIdBeforeDetailsViewOpened);
}
_elementIdBeforeDetailsViewOpened = null;
}
private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName);
private bool HasMultipleReplicas(ResourceViewModel resource)
{
var count = 0;
foreach (var (_, item) in _resourceByName)
{
if (item.IsHiddenState())
{
continue;
}
if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName))
{
count++;
if (count >= 2)
{
return true;
}
}
}
return false;
}
private string GetRowClass(ResourceViewModel resource)
=> string.Equals(resource.Name, SelectedResource?.Name, StringComparisons.ResourceName) ? "selected-row resource-row" : "resource-row";
private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command)
{
await DashboardCommandExecutor.ExecuteAsync(resource, command, GetResourceName);
}
private static string GetEndpointsTooltip(ResourceViewModel resource)
{
var displayedEndpoints = GetDisplayedEndpoints(resource);
if (displayedEndpoints.Count == 0)
{
return string.Empty;
}
if (displayedEndpoints.Count == 1)
{
return displayedEndpoints[0].Text;
}
var maxShownEndpoints = 3;
var tooltipBuilder = new StringBuilder(string.Join(", ", displayedEndpoints.Take(maxShownEndpoints).Select(endpoint => endpoint.Text)));
if (displayedEndpoints.Count > maxShownEndpoints)
{
tooltipBuilder.Append(CultureInfo.CurrentCulture, $" + {displayedEndpoints.Count - maxShownEndpoints}");
}
return tooltipBuilder.ToString();
}
private async Task OnToggleCollapse(ResourceGridViewModel viewModel)
{
// View model data is recreated if data updates.
// Persist the collapsed state in a separate list.
viewModel.IsCollapsed = !viewModel.IsCollapsed;
if (viewModel.IsCollapsed)
{
_collapsedResourceNames.Add(viewModel.Resource.Name);
}
else
{
_collapsedResourceNames.Remove(viewModel.Resource.Name);
}
await SessionStorage.SetAsync(BrowserStorageKeys.ResourcesCollapsedResourceNames, _collapsedResourceNames.ToList());
await _dataGrid.SafeRefreshDataAsync();
UpdateMenuButtons();
}
private async Task OnToggleCollapseAll()
{
var resourcesWithChildren = _resourceByName.Values
.Where(r => !r.IsHiddenState())
.Where(r => _resourceByName.Values.Any(nested => nested.GetResourcePropertyValue(KnownProperties.Resource.ParentName) == r.Name))
.ToList();
if (HasCollapsedResources())
{
foreach (var resource in resourcesWithChildren)
{
_collapsedResourceNames.Remove(resource.Name);
}
}
else
{
foreach (var resource in resourcesWithChildren)
{
_collapsedResourceNames.Add(resource.Name);
}
}
await SessionStorage.SetAsync(BrowserStorageKeys.ResourcesCollapsedResourceNames, _collapsedResourceNames.ToList());
await _dataGrid.SafeRefreshDataAsync();
UpdateMenuButtons();
}
private static List<DisplayedEndpoint> GetDisplayedEndpoints(ResourceViewModel resource)
{
return ResourceEndpointHelpers.GetEndpoints(resource, includeInternalUrls: false);
}
private bool HasAnyChildResources()
{
return _resourceByName.Values.Any(r => !string.IsNullOrEmpty(r.GetResourcePropertyValue(KnownProperties.Resource.ParentName)));
}
private Task OnTabChangeAsync(FluentTab newTab)
{
var id = newTab.Id?.Substring("tab-".Length);
if (id is null
|| !Enum.TryParse(typeof(ResourceViewKind), id, out var o)
|| o is not ResourceViewKind viewKind)
{
return Task.CompletedTask;
}
return OnViewChangedAsync(viewKind);
}
private async Task OnViewChangedAsync(ResourceViewKind newView)
{
PageViewModel.SelectedViewKind = newView;
await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: true);
if (newView == ResourceViewKind.Graph)
{
await UpdateResourceGraphResourcesAsync();
await UpdateResourceGraphSelectedAsync();
}
}
private async Task UpdateResourceGraphSelectedAsync()
{
if (_jsModule != null)
{
await _jsModule.InvokeVoidAsync("updateResourcesGraphSelected", SelectedResource?.Name);
}
}
public sealed class ResourcesViewModel
{
public required ResourceViewKind SelectedViewKind { get; set; }
}
public class ResourcesPageState
{
public required string? ViewKind { get; set; }
}
public enum ResourceViewKind
{
Table,
Graph
}
public Task UpdateViewModelFromQueryAsync(ResourcesViewModel viewModel)
{
if (Enum.TryParse(typeof(ResourceViewKind), ViewKindName, out var view) && view is ResourceViewKind vk)
{
viewModel.SelectedViewKind = vk;
}
return Task.CompletedTask;
}
public string GetUrlFromSerializableViewModel(ResourcesPageState serializable)
{
return DashboardUrls.ResourcesUrl(view: serializable.ViewKind);
}
public ResourcesPageState ConvertViewModelToSerializable()
{
return new ResourcesPageState
{
ViewKind = (PageViewModel.SelectedViewKind != ResourceViewKind.Table) ? PageViewModel.SelectedViewKind.ToString() : null
};
}
public async ValueTask DisposeAsync()
{
_resourcesInteropReference?.Dispose();
_watchTaskCancellationTokenSource.Cancel();
_watchTaskCancellationTokenSource.Dispose();
_logsSubscription?.Dispose();
await JSInteropHelpers.SafeDisposeAsync(_jsModule);
await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask);
}
}
|