From 122b11aa7b714a83eff10fd588d0d91efd780048 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:03:22 +0000 Subject: [PATCH 1/8] Initial plan From fffd4abb26fd73d713d599b0b526d42e179ea2e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:21:59 +0000 Subject: [PATCH 2/8] Add Parameters page to separate parameter resources from active resources Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com> --- .../Components/Layout/DesktopNavMenu.razor | 5 + .../Components/Layout/DesktopNavMenu.razor.cs | 4 + .../Components/Layout/MobileNavMenu.razor.cs | 7 + .../Components/Pages/Parameters.razor | 246 +++++ .../Components/Pages/Parameters.razor.cs | 958 ++++++++++++++++++ .../Components/Pages/Parameters.razor.css | 194 ++++ .../Components/Pages/Resources.razor.cs | 3 +- .../Resources/Layout.Designer.cs | 6 + src/Aspire.Dashboard/Resources/Layout.resx | 3 + .../Resources/Resources.Designer.cs | 18 + src/Aspire.Dashboard/Resources/Resources.resx | 7 + .../Resources/xlf/Layout.cs.xlf | 5 + .../Resources/xlf/Layout.de.xlf | 5 + .../Resources/xlf/Layout.es.xlf | 5 + .../Resources/xlf/Layout.fr.xlf | 5 + .../Resources/xlf/Layout.it.xlf | 5 + .../Resources/xlf/Layout.ja.xlf | 5 + .../Resources/xlf/Layout.ko.xlf | 5 + .../Resources/xlf/Layout.pl.xlf | 5 + .../Resources/xlf/Layout.pt-BR.xlf | 5 + .../Resources/xlf/Layout.ru.xlf | 5 + .../Resources/xlf/Layout.tr.xlf | 5 + .../Resources/xlf/Layout.zh-Hans.xlf | 5 + .../Resources/xlf/Layout.zh-Hant.xlf | 5 + .../Resources/xlf/Resources.cs.xlf | 10 + .../Resources/xlf/Resources.de.xlf | 10 + .../Resources/xlf/Resources.es.xlf | 10 + .../Resources/xlf/Resources.fr.xlf | 10 + .../Resources/xlf/Resources.it.xlf | 10 + .../Resources/xlf/Resources.ja.xlf | 10 + .../Resources/xlf/Resources.ko.xlf | 10 + .../Resources/xlf/Resources.pl.xlf | 10 + .../Resources/xlf/Resources.pt-BR.xlf | 10 + .../Resources/xlf/Resources.ru.xlf | 10 + .../Resources/xlf/Resources.tr.xlf | 10 + .../Resources/xlf/Resources.zh-Hans.xlf | 10 + .../Resources/xlf/Resources.zh-Hant.xlf | 10 + .../Utils/BrowserStorageKeys.cs | 1 + src/Aspire.Dashboard/Utils/DashboardUrls.cs | 28 + 39 files changed, 1674 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Dashboard/Components/Pages/Parameters.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Pages/Parameters.razor.css diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor index b5b78e8f5ef..c80b100c167 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor @@ -12,6 +12,11 @@ IconRest="ResourcesIcon()" IconActive="ResourcesIcon(active: true)" Text="@Loc[nameof(Layout.NavMenuResourcesTab)]" /> + active ? new Icons.Filled.Size24.AppFolder() : new Icons.Regular.Size24.AppFolder(); + internal static Icon ParametersIcon(bool active = false) => + active ? new Icons.Filled.Size24.Key() + : new Icons.Regular.Size24.Key(); + internal static Icon ConsoleLogsIcon(bool active = false) => active ? new Icons.Filled.Size24.SlideText() : new Icons.Regular.Size24.SlideText(); diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index 42ab3f27090..afff2da59a8 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -45,6 +45,13 @@ private IEnumerable GetMobileNavMenuEntries() LinkMatchRegex: new Regex($"^{DashboardUrls.ResourcesUrl()}(\\?.*)?$") ); + yield return new MobileNavMenuEntry( + Loc[nameof(Resources.Layout.NavMenuParametersTab)], + () => NavigateToAsync(DashboardUrls.ParametersUrl()), + DesktopNavMenu.ParametersIcon(), + LinkMatchRegex: GetNonIndexPageRegex(DashboardUrls.ParametersUrl()) + ); + yield return new MobileNavMenuEntry( Loc[nameof(Resources.Layout.NavMenuConsoleLogsTab)], () => NavigateToAsync(DashboardUrls.ConsoleLogsUrl()), diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor b/src/Aspire.Dashboard/Components/Pages/Parameters.razor new file mode 100644 index 00000000000..a170fcf4c19 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Parameters.razor @@ -0,0 +1,246 @@ +@page "/parameters" +@using Aspire.Dashboard.Components.ResourcesGridColumns +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils +@using System.Globalization +@using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.Dashboard.Model +@using Humanizer +@inject IStringLocalizer Loc +@inject IStringLocalizer ControlsStringsLoc +@inject IStringLocalizer ColumnsLoc +@inject IStringLocalizer CommandsLoc + + + +@{ + var showDetailsView = SelectedResource is not null; +} + +
+ + +

@Loc[nameof(Dashboard.Resources.Resources.ParametersHeader)]

+
+ + + + + + + @if (ViewportInformation.IsDesktop) + { + + + @if (HasAnyChildResources()) + { + + } + } + else + { + foreach (var item in _resourcesMenuItems) + { + + @item.Text + + } + +
+ +
+ } +
+ + + + + + + + + + +
+
+ @* + Tab content isn't nested inside FluentTab elements. The tab control is just used to display the tabs. + Content is located in manually created divs so they can be placed in their own CSS grid row. + *@ + @if (!_hideResourceGraph) + { + + + + + + + } + + @if (!_hideResourceGraph) + { + + } +
+
+ + +
+
+ +
+
+
+ + @* Don't display footer with the resource graph *@ + + +
+
diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs new file mode 100644 index 00000000000..96b191c6814 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs @@ -0,0 +1,958 @@ +// 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.Configuration; +using Aspire.Dashboard.Extensions; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Assistant; +using Aspire.Dashboard.Model.ResourceGraph; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.Telemetry; +using Aspire.Dashboard.Utils; +using Aspire.Hosting.Utils; +using Humanizer; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; + +namespace Aspire.Dashboard.Components.Pages; + +public partial class Parameters : ComponentBase, IComponentWithTelemetry, IAsyncDisposable, IPageWithSessionAndUrlState +{ + 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 UrlsColumn = nameof(UrlsColumn); + private const string ActionsColumn = nameof(ActionsColumn); + + private Subscription? _logsSubscription; + private IList? _gridColumns; + private EventCallback _onToggleCollapseAllCallback; + private EventCallback _onToggleResourceTypeCallback; + private bool _hideResourceGraph; + private Dictionary? _resourceUnviewedErrorCounts; + + [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; } + [Inject] + public required IAIContextProvider AIContextProvider { get; init; } + [Inject] + public required IOptionsMonitor DashboardOptions { get; init; } + [Inject] + public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; } + [Inject] + public required ILogger Logger { get; init; } + [Inject] + public required IStringLocalizer AIAssistantLoc { get; init; } + [Inject] + public required IStringLocalizer AIPromptsLoc { get; init; } + [Inject] + public required IconResolver IconResolver { get; init; } + + public string BasePath => DashboardUrls.ParametersBasePath; + public string SessionStorageKey => BrowserStorageKeys.ParametersPageState; + public ParametersViewModel PageViewModel { get; set; } = null!; + + [Parameter] + [SupplyParameterFromQuery(Name = "view")] + public string? ViewKindName { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "showHiddenResources")] + public bool ShowHiddenResources { get; set; } + + [CascadingParameter] + public required ViewportInformation ViewportInformation { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? HiddenTypes { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? HiddenStates { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? HiddenHealthStates { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "resource")] + public string? ResourceName { get; set; } + + private ResourceViewModel? SelectedResource { get; set; } + + private readonly CancellationTokenSource _watchTaskCancellationTokenSource = new(); + private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); + private readonly HashSet _collapsedResourceNames = new(StringComparers.ResourceName); + private readonly TaskCompletionSource _loadingTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string _filter = ""; + private bool _isFilterPopupVisible; + private Task? _resourceSubscriptionTask; + private string? _elementIdBeforeDetailsViewOpened; + private FluentDataGrid _dataGrid = null!; + private GridColumnManager _manager = null!; + private int _maxHighlightedCount; + private readonly List _resourcesMenuItems = new(); + private DotNetObjectReference? _resourcesInteropReference; + private IJSObjectReference? _jsModule; + private bool _graphInitialized; + private AspirePageContentLayout? _contentLayout; + private TotalItemsFooter _totalItemsFooter = default!; + private int _totalItemsCount; + + private AspireMenu? _contextMenu; + private bool _contextMenuOpen; + private readonly List _contextMenuItems = new(); + private TaskCompletionSource? _contextMenuClosedTcs; + + private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; + private ColumnSortLabels _sortLabels = ColumnSortLabels.Default; + private bool _showResourceTypeColumn; + private AIContext? _aiContext; + private bool _showHiddenResources; + + private bool Filter(ResourceViewModel resource) + { + return IsKeyValueTrue(resource.ResourceType, PageViewModel.ResourceTypesToVisibility) + && IsKeyValueTrue(resource.State ?? string.Empty, PageViewModel.ResourceStatesToVisibility) + && IsKeyValueTrue(resource.HealthStatus?.Humanize() ?? string.Empty, PageViewModel.ResourceHealthStatusesToVisibility) + && (_filter.Length == 0 || resource.MatchesFilter(_filter)) + && !resource.IsResourceHidden(_showHiddenResources) + && StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Parameter); + + static bool IsKeyValueTrue(string key, IDictionary dictionary) => dictionary.TryGetValue(key, out var value) && value; + } + + private async Task OnAllFilterVisibilityCheckedChangedAsync() + { + await ClearSelectedResourceAsync(); + await _dataGrid.SafeRefreshDataAsync(); + UpdateMenuButtons(); + await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false); + } + + private async Task OnResourceFilterVisibilityChangedAsync(string resourceType, bool isVisible) + { + await UpdateResourceGraphResourcesAsync(); + await ClearSelectedResourceAsync(); + await _dataGrid.SafeRefreshDataAsync(); + UpdateMenuButtons(); + await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false); + } + + private async Task HandleSearchFilterChangedAsync() + { + await UpdateResourceGraphResourcesAsync(); + await ClearSelectedResourceAsync(); + await _dataGrid.SafeRefreshDataAsync(); + } + + // Internal for tests + internal bool NoFiltersSet => AreAllTypesVisible && AreAllStatesVisible && AreAllHealthStatesVisible; + internal bool AreAllTypesVisible => PageViewModel.ResourceTypesToVisibility.Values.All(value => value); + internal bool AreAllStatesVisible => PageViewModel.ResourceStatesToVisibility.Values.All(value => value); + internal bool AreAllHealthStatesVisible => PageViewModel.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); + private readonly GridSort _typeSort = GridSort.ByAscending(p => p.Resource.ResourceType).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance); + + protected override async Task OnInitializedAsync() + { + TelemetryContextProvider.Initialize(TelemetryContext); + _aiContext = AIContextProvider.AddNew(nameof(Parameters), c => + { + c.BuildIceBreakers = (builder, context) => + { + var hasUnhealthyResources = _resourceByName.Values + .Where(r => !r.IsResourceHidden(_showHiddenResources)) + .Any(r => r.KnownState != KnownResourceState.Running || r.HealthStatus is HealthStatus.Unhealthy or HealthStatus.Degraded); + + builder.Resources(context, hasUnhealthyResources); + }; + }); + + (_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", IsVisible: () => _showResourceTypeColumn), + new GridColumn(Name: SourceColumn, DesktopWidth: "2.25fr"), + new GridColumn(Name: UrlsColumn, DesktopWidth: "2.25fr", MobileWidth: "2fr"), + new GridColumn(Name: ActionsColumn, DesktopWidth: "minmax(150px, 1.5fr)", MobileWidth: "1fr") + ]; + + _onToggleCollapseAllCallback = EventCallback.Factory.Create(this, OnToggleCollapseAll); + _onToggleResourceTypeCallback = EventCallback.Factory.Create(this, OnToggleResourceType); + + _hideResourceGraph = DashboardOptions.CurrentValue.UI.DisableResourceGraph ?? false; + + PageViewModel = new ParametersViewModel + { + SelectedViewKind = ResourceViewKind.Table + }; + + _resourceUnviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount(); + + var showResourceTypeColumn = await SessionStorage.GetAsync(BrowserStorageKeys.ResourcesShowResourceTypes); + if (showResourceTypeColumn.Success) + { + _showResourceTypeColumn = showResourceTypeColumn.Value; + } + + var showHiddenResources = await SessionStorage.GetAsync(BrowserStorageKeys.ResourcesShowHiddenResources); + if (showHiddenResources.Success) + { + _showHiddenResources = showHiddenResources.Value; + } + UpdateMenuButtons(); + + if (DashboardClient.IsEnabled) + { + var collapsedResult = await SessionStorage.GetAsync>(BrowserStorageKeys.ResourcesCollapsedResourceNames); + if (collapsedResult.Success) + { + foreach (var resourceName in collapsedResult.Value) + { + _collapsedResourceNames.Add(resourceName); + } + } + + await SubscribeResourcesAsync(); + } + + _logsSubscription = TelemetryRepository.OnNewLogs(null, SubscriptionType.Other, async () => + { + var newResourceUnviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount(); + + // Only update UI if the error counts have changed. + if (ResourceErrorCountsChanged(newResourceUnviewedErrorCounts)) + { + _resourceUnviewedErrorCounts = newResourceUnviewedErrorCounts; + await InvokeAsync(_dataGrid.SafeRefreshDataAsync); + } + }); + + _loadingTcs.SetResult(); + + async Task SubscribeResourcesAsync() + { + var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_watchTaskCancellationTokenSource.Token); + + // Apply snapshot. + foreach (var resource in snapshot) + { + var added = UpdateFromResource(resource); + 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, + // The new type/state/health status should be visible if it's either + // 1) new, or + // 2) previously visible + t => !PageViewModel.ResourceTypesToVisibility.TryGetValue(t, out var value) || value, + s => !PageViewModel.ResourceStatesToVisibility.TryGetValue(s, out var value) || value, + s => !PageViewModel.ResourceHealthStatusesToVisibility.TryGetValue(s, out var value) || value); + + 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(); + _aiContext?.ContextHasChanged(); + 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(); + } + }); + } + }); + } + } + + private bool UpdateFromResource(ResourceViewModel resource) + { + var preselectedHiddenResourceTypes = HiddenTypes?.Split(' ').Select(StringUtils.Unescape).ToHashSet(); + var preselectedHiddenResourceStates = HiddenStates?.Split(' ').Select(StringUtils.Unescape).ToHashSet(); + var preselectedHiddenResourceHealthStates = HiddenHealthStates?.Split(' ').Select(StringUtils.Unescape).ToHashSet(); + + return UpdateFromResource( + resource, + type => preselectedHiddenResourceTypes is null || !preselectedHiddenResourceTypes.Contains(type), + state => preselectedHiddenResourceStates is null || !preselectedHiddenResourceStates.Contains(state), + healthStatus => preselectedHiddenResourceHealthStates is null || !preselectedHiddenResourceHealthStates.Contains(healthStatus)); + } + + private bool UpdateFromResource(ResourceViewModel resource, Func resourceTypeVisible, Func stateVisible, Func 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); + } + + PageViewModel.ResourceTypesToVisibility.AddOrUpdate(resource.ResourceType, resourceTypeVisible(resource.ResourceType), (_, _) => resourceTypeVisible(resource.ResourceType)); + PageViewModel.ResourceStatesToVisibility.AddOrUpdate(resource.State ?? string.Empty, stateVisible(resource.State ?? string.Empty), (_, _) => stateVisible(resource.State ?? string.Empty)); + PageViewModel.ResourceHealthStatusesToVisibility.AddOrUpdate(resource.HealthStatus?.Humanize() ?? string.Empty, healthStatusVisible(resource.HealthStatus?.Humanize() ?? string.Empty), (_, _) => healthStatusVisible(resource.HealthStatus?.Humanize() ?? string.Empty)); + + UpdateMenuButtons(); + + return added; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Check to see whether max item count should be set on every render. + // This is required because the data grid's virtualize component can be recreated on data change. + if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) + { + StateHasChanged(); + } + + if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph && !_graphInitialized) + { + // Before any awaits, set a flag to indicate the graph is initialized. This prevents the graph being initialized multiple times. + _graphInitialized = true; + + _jsModule = await JS.InvokeAsync("import", "/js/app-resourcegraph.js"); + + _resourcesInteropReference = DotNetObjectReference.Create(new ParametersInterop(this)); + + await _jsModule.InvokeVoidAsync("initializeResourcesGraph", _resourcesInteropReference); + await UpdateResourceGraphResourcesAsync(); + await UpdateResourceGraphSelectedAsync(); + } + } + + 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, _showHiddenResources, IconResolver)).ToList(); + await _jsModule.InvokeVoidAsync("updateResourcesGraph", resources); + } + + private class ParametersInterop(Parameters parameters) + { + [JSInvokable] + public async Task SelectResource(string id) + { + if (parameters._resourceByName.TryGetValue(id, out var resource)) + { + await parameters.InvokeAsync(async () => + { + await parameters.ShowResourceDetailsAsync(resource, null!); + parameters.StateHasChanged(); + }); + } + } + + [JSInvokable] + public async Task ResourceContextMenu(string id, int screenWidth, int screenHeight, int clientX, int clientY) + { + if (parameters._resourceByName.TryGetValue(id, out var resource)) + { + await parameters.InvokeAsync(async () => + { + await parameters.ShowContextMenuAsync(resource, screenWidth, screenHeight, clientX, clientY); + }); + } + } + } + + internal IEnumerable GetFilteredResources() + { + return _resourceByName + .Values + .Where(Filter); + } + + private ValueTask> GetData(GridItemsProviderRequest 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(); + + _totalItemsCount = orderedResources.Count; + _totalItemsFooter.UpdateDisplayedCount(query.Count); + + return ValueTask.FromResult(GridItemsProviderResult.From(query, orderedResources.Count)); + } + + private void UpdateMenuButtons() + { + _resourcesMenuItems.Clear(); + + if (HasCollapsedResources()) + { + _resourcesMenuItems.Add(new MenuButtonItem + { + IsDisabled = false, + OnClick = _onToggleCollapseAllCallback.InvokeAsync, + Text = Loc[nameof(Dashboard.Resources.Resources.ResourceExpandAllChildren)], + Icon = new Icons.Regular.Size16.Eye() + }); + } + else + { + _resourcesMenuItems.Add(new MenuButtonItem + { + IsDisabled = false, + OnClick = _onToggleCollapseAllCallback.InvokeAsync, + Text = Loc[nameof(Dashboard.Resources.Resources.ResourceCollapseAllChildren)], + Icon = new Icons.Regular.Size16.EyeOff() + }); + } + + if (_showResourceTypeColumn) + { + _resourcesMenuItems.Add(new MenuButtonItem + { + IsDisabled = false, + OnClick = _onToggleResourceTypeCallback.InvokeAsync, + Text = Loc[nameof(Dashboard.Resources.Resources.ResourcesHideTypes)], + Icon = new Icons.Regular.Size16.EyeOff() + }); + } + else + { + _resourcesMenuItems.Add(new MenuButtonItem + { + IsDisabled = false, + OnClick = _onToggleResourceTypeCallback.InvokeAsync, + Text = Loc[nameof(Dashboard.Resources.Resources.ResourcesShowTypes)], + Icon = new Icons.Regular.Size16.Eye() + }); + } + + CommonMenuItems.AddToggleHiddenResourcesMenuItem( + _resourcesMenuItems, + ControlsStringsLoc, + _showHiddenResources, + _resourceByName.Values, + SessionStorage, + EventCallback.Factory.Create(this, + async value => + { + _showHiddenResources = value; + UpdateMenuButtons(); + await _dataGrid.SafeRefreshDataAsync(); + })); + } + + private bool HasCollapsedResources() + { + return _resourceByName.Any(r => !r.Value.IsResourceHidden(_showHiddenResources) && _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; + } + + // Wait until the initial data is loaded. This is required so there isn't a race between data loading and using resources here. + await _loadingTcs.Task; + + // If filters were saved in page state, resource filters now need to be recomputed since the URL has changed. + foreach (var resourceViewModel in _resourceByName) + { + UpdateFromResource(resourceViewModel.Value); + } + + if (ResourceName is not null) + { + if (_resourceByName.TryGetValue(ResourceName, out var selectedResource)) + { + await ShowResourceDetailsAsync(selectedResource, buttonId: null); + } + else + { + Logger.LogDebug("Can't navigate to {ResourceName} from URL. Resource not found.", ResourceName); + } + + // Navigate to remove ?resource=xxx in the URL. + NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); + } + + UpdateTelemetryProperties(); + } + + private bool ResourceErrorCountsChanged(Dictionary newResourceUnviewedErrorCounts) + { + if (_resourceUnviewedErrorCounts == null || _resourceUnviewedErrorCounts.Count != newResourceUnviewedErrorCounts.Count) + { + return true; + } + + foreach (var (resource, count) in newResourceUnviewedErrorCounts) + { + if (!_resourceUnviewedErrorCounts.TryGetValue(resource, out var oldCount) || oldCount != count) + { + return true; + } + } + + return false; + } + + private async Task ShowContextMenuAsync(ResourceViewModel resource, int screenWidth, int screenHeight, int clientX, int clientY) + { + // This is called when the browser requests to show the context menu for a resource. + // The method doesn't complete until the context menu is closed so the browser can await + // it and perform clean up when the context menu is closed. + if (_contextMenu is { } contextMenu) + { + _contextMenuItems.Clear(); + ResourceMenuItems.AddMenuItems( + _contextMenuItems, + resource, + NavigationManager, + TelemetryRepository, + AIContextProvider, + GetResourceName, + ControlsStringsLoc, + Loc, + AIAssistantLoc, + AIPromptsLoc, + CommandsLoc, + EventCallback.Factory.Create(this, () => ShowResourceDetailsAsync(resource, buttonId: null)), + EventCallback.Factory.Create(this, (command) => ExecuteResourceCommandAsync(resource, command)), + (resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name), + showConsoleLogsItem: true, + showUrls: true, + IconResolver); + + // The previous context menu should always be closed by this point but complete just in case. + _contextMenuClosedTcs?.TrySetResult(); + + _contextMenuClosedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await contextMenu.OpenAsync(screenWidth, screenHeight, clientX, clientY); + StateHasChanged(); + + // Completed when the overlay closes. + await _contextMenuClosedTcs.Task; + } + } + + private async Task ShowResourceDetailsAsync(ResourceViewModel resource, string? buttonId) + { + Logger.LogDebug("Showing details for resource {ResourceName}.", resource.Name); + + _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; + } + + if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph) + { + await UpdateResourceGraphSelectedAsync(); + } + + await _dataGrid.SafeRefreshDataAsync(); + } + } + + private async Task ClearSelectedResourceAsync(bool causedByUserAction = false) + { + Logger.LogDebug("Clearing selected resource."); + + 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, _showHiddenResources); + + private bool HasMultipleReplicas(ResourceViewModel resource) + { + var count = 0; + foreach (var (_, item) in _resourceByName) + { + if (item.IsResourceHidden(_showHiddenResources)) + { + 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 GetUrlsTooltip(ResourceViewModel resource) + { + var displayedUrls = GetDisplayedUrls(resource); + + if (displayedUrls.Count == 0) + { + return string.Empty; + } + + if (displayedUrls.Count == 1) + { + return displayedUrls[0].Text; + } + + var maxShownUrls = 3; + var tooltipBuilder = new StringBuilder(string.Join(", ", displayedUrls.Take(maxShownUrls).Select(url => url.Text))); + + if (displayedUrls.Count > maxShownUrls) + { + tooltipBuilder.Append(CultureInfo.CurrentCulture, $" + {displayedUrls.Count - maxShownUrls}"); + } + + 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.IsResourceHidden(_showHiddenResources)) + .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 async Task OnToggleResourceType() + { + _showResourceTypeColumn = !_showResourceTypeColumn; + await SessionStorage.SetAsync(BrowserStorageKeys.ResourcesShowResourceTypes, _showResourceTypeColumn); + await _dataGrid.SafeRefreshDataAsync(); + UpdateMenuButtons(); + } + + private static List GetDisplayedUrls(ResourceViewModel resource) + { + return ResourceUrlHelpers.GetUrls(resource, includeInternalUrls: false, includeNonEndpointUrls: true); + } + + 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 + || PageViewModel.SelectedViewKind == 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 ParametersViewModel + { + public required ResourceViewKind SelectedViewKind { get; set; } + public ConcurrentDictionary ResourceTypesToVisibility { get; } = new(StringComparers.ResourceName); + public ConcurrentDictionary ResourceStatesToVisibility { get; } = new(StringComparers.ResourceState); + public ConcurrentDictionary ResourceHealthStatusesToVisibility { get; } = new(StringComparer.Ordinal); + } + + public class ParametersPageState + { + public required string? ViewKind { get; set; } + public required IDictionary ResourceTypesToVisibility { get; set; } + public required IDictionary ResourceStatesToVisibility { get; set; } + public required IDictionary ResourceHealthStatusesToVisibility { get; set; } + } + + public enum ResourceViewKind + { + Table, + Graph + } + + public Task UpdateViewModelFromQueryAsync(ParametersViewModel viewModel) + { + // Don't allow the view to be updated from the query string if the resource graph is disabled. + if (!_hideResourceGraph && Enum.TryParse(typeof(ResourceViewKind), ViewKindName, out var view) && view is ResourceViewKind vk) + { + viewModel.SelectedViewKind = vk; + } + + return Task.CompletedTask; + } + + public string GetUrlFromSerializableViewModel(ParametersPageState serializable) + { + return DashboardUrls.ParametersUrl( + view: serializable.ViewKind, + // add resource? + hiddenTypes: SerializeFiltersToString(serializable.ResourceTypesToVisibility), + hiddenStates: SerializeFiltersToString(serializable.ResourceStatesToVisibility), + hiddenHealthStates: SerializeFiltersToString(serializable.ResourceHealthStatusesToVisibility)); + + static string? SerializeFiltersToString(IDictionary filters) + { + var escapedFilters = filters.Where(kvp => !kvp.Value).Select(kvp => StringUtils.Escape(kvp.Key)).ToList(); + return escapedFilters.Count == 0 ? null : string.Join(" ", escapedFilters); + } + } + + public ParametersPageState ConvertViewModelToSerializable() + { + return new ParametersPageState + { + ViewKind = PageViewModel.SelectedViewKind != ResourceViewKind.Table ? PageViewModel.SelectedViewKind.ToString() : null, + ResourceTypesToVisibility = PageViewModel.ResourceTypesToVisibility, + ResourceStatesToVisibility = PageViewModel.ResourceStatesToVisibility, + ResourceHealthStatusesToVisibility = PageViewModel.ResourceHealthStatusesToVisibility + }; + } + + public async ValueTask DisposeAsync() + { + _aiContext?.Dispose(); + + _resourcesInteropReference?.Dispose(); + _watchTaskCancellationTokenSource.Cancel(); + _logsSubscription?.Dispose(); + TelemetryContext.Dispose(); + await JSInteropHelpers.SafeDisposeAsync(_jsModule); + + await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); + } + + private async Task ContextMenuClosed(Microsoft.AspNetCore.Components.Web.MouseEventArgs args) + { + if (_contextMenu is { } menu) + { + await menu.CloseAsync(); + } + + _contextMenuClosedTcs?.TrySetResult(); + _contextMenuClosedTcs = null; + } + + // IComponentWithTelemetry impl + public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.Resources); + + public void UpdateTelemetryProperties() + { + var properties = new List + { + new(TelemetryPropertyKeys.ResourceView, new AspireTelemetryProperty(PageViewModel.SelectedViewKind.ToString(), AspireTelemetryPropertyType.UserSetting)), + new(TelemetryPropertyKeys.ResourceTypes, new AspireTelemetryProperty(_resourceByName.Values.Select(r => TelemetryPropertyValues.GetResourceTypeTelemetryValue(r.ResourceType, r.SupportsDetailedTelemetry)).OrderBy(t => t).ToList())) + }; + + TelemetryContext.UpdateTelemetryProperties(properties.ToArray(), Logger); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.css b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.css new file mode 100644 index 00000000000..643547fecb2 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.css @@ -0,0 +1,194 @@ +::deep .unread-logs-errors-link { + vertical-align: super; + --unread-logs-badge-color: #ffffff; +} + +::deep .unread-logs-errors-link::part(control) { + margin-left: 5px; + padding: 0; + flex-grow: 0; + color: #ffffff; +} + +::deep .error-counter-badge { + padding: 1px; +} + +/* Forces the grid to always take up the width of its rows, + and the rows to always take up the width of the cells. + This fixes a few odd rendering things, but also enables us + to set overflow-x: clip on the grid to prevent an odd visual + glitch with the menu when it is at the far right of the viewport +*/ +::deep .fluent-data-grid, +::deep .fluent-data-grid-row { + min-width: min-content; +} + +/* Ensures that any popups (e.g. menus) don't overflow past + the right edge of the grid causing a visual flicker of + the horizontal scrollbar appearing and disappearing +*/ +::deep .fluent-data-grid { + overflow-x: clip; +} + +::deep .resources-name-container { + height: 24px; + display: inline-flex; + vertical-align: middle; + align-items: center; +} + +::deep tr.resource-row > :first-child { + padding-inline-start: 0; +} + +::deep .resources-summary-layout { + display: grid; + grid-template-rows: 1fr auto; + height: 100%; + width: 100%; + grid-template-areas: + "main" + "foot"; +} + +::deep .resource-tabs { + grid-area: main; + display: grid; + grid-template-areas: + "resources-tab-header" + "resources-tab-content"; + grid-template-rows: auto 1fr; + overflow: hidden; +} + +::deep .resource-tabs fluent-tabs { + margin-left: calc(var(--design-unit) * 3px); +} + +::deep .resources-grid-container { + overflow: auto; + grid-area: resources-tab-content; +} + +::deep .resource-tabs .resources-grid-container { + margin-top: 10px; +} + +::deep .resource-graph-container { + grid-area: resources-tab-content; + position: relative; /* So graph buttons are position inside the container */ +} + +::deep .resource-graph-controls { + position: absolute; + right: 30px; + bottom: 30px; + display: flex; + column-gap: 10px; +} + +::deep .resource-graph .texts { + cursor: pointer; +} + +::deep .resource-graph .nodes { + cursor: pointer; +} + +::deep .resource-name { + fill: var(--neutral-foreground-rest); + stroke: var(--fill-color); + font-size: 15px; + text-anchor: middle; + stroke-width: 0.5em; + paint-order: stroke; + stroke-linejoin: round; +} + +::deep .resource-node { + stroke: var(--fill-color); + fill: var(--fill-color); +} + +::deep .resource-group-hover .resource-node { + fill: var(--neutral-fill-hover) !important; +} + +::deep .resource-group-selected .resource-scale { + transform: scale(1.2); +} + +::deep .resource-group-selected .resource-node { + fill: var(--neutral-fill-secondary-rest) !important; +} + +::deep .resource-group-selected .resource-node-border { + stroke: var(--neutral-stroke-hover); +} + +::deep .resource-group-highlight .resource-node { + fill: url(#highlighted-pattern); +} + +::deep .resource-group-highlight .resource-node-border { + stroke: var(--neutral-stroke-hover); +} + +::deep .resource-group-hover .resource-node-border { + stroke: var(--neutral-stroke-hover); +} + +::deep .resource-node-border { + stroke: var(--neutral-stroke-rest); + stroke-width: 1; + fill: transparent; +} + +::deep .resource-endpoint { + fill: var(--foreground-subtext-rest); + font-size: 11px; + text-anchor: middle; +} + +::deep .resource-status-circle { + fill: var(--fill-color); +} + +::deep .resource-link { + stroke: var(--neutral-stroke-rest); + stroke-width: 1; + marker-end: url(#arrow-normal); +} + +::deep .arrow-normal { + fill: var(--neutral-stroke-rest); +} + +::deep .arrow-highlight { + fill: var(--neutral-stroke-hover); +} + +::deep .arrow-highlight-expand { + fill: var(--neutral-stroke-hover); +} + +::deep .resource-link-highlight { + stroke: var(--neutral-stroke-hover); + stroke-dasharray: 5,5; + stroke-width: 2; + marker-end: url(#arrow-highlight); +} + +::deep .resource-link-highlight-expand { + stroke: var(--neutral-stroke-hover); + stroke-dasharray: 5,5; + stroke-width: 2; + marker-end: url(#arrow-highlight-expand); +} + +::deep .tab-label > svg { + margin-right: calc(var(--design-unit) * 1px); +} diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 079d378df49..ae182895067 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -141,7 +141,8 @@ private bool Filter(ResourceViewModel resource) && IsKeyValueTrue(resource.State ?? string.Empty, PageViewModel.ResourceStatesToVisibility) && IsKeyValueTrue(resource.HealthStatus?.Humanize() ?? string.Empty, PageViewModel.ResourceHealthStatusesToVisibility) && (_filter.Length == 0 || resource.MatchesFilter(_filter)) - && !resource.IsResourceHidden(_showHiddenResources); + && !resource.IsResourceHidden(_showHiddenResources) + && !StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Parameter); static bool IsKeyValueTrue(string key, IDictionary dictionary) => dictionary.TryGetValue(key, out var value) && value; } diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 486d351f3a9..dfdc14bf571 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -170,5 +170,11 @@ public static string ReconnectRetryButtonText { return ResourceManager.GetString("ReconnectRetryButtonText", resourceCulture); } } + + public static string NavMenuParametersTab { + get { + return ResourceManager.GetString("NavMenuParametersTab", resourceCulture); + } + } } } diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 2618ef1049d..a91a6c4778a 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -180,4 +180,7 @@ Retry + + Parameters + diff --git a/src/Aspire.Dashboard/Resources/Resources.Designer.cs b/src/Aspire.Dashboard/Resources/Resources.Designer.cs index a47d582246d..c9ea05a2533 100644 --- a/src/Aspire.Dashboard/Resources/Resources.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Resources.Designer.cs @@ -599,5 +599,23 @@ public static string WaitingHealthDataStatusMessage { return ResourceManager.GetString("WaitingHealthDataStatusMessage", resourceCulture); } } + + /// + /// Looks up a localized string similar to Parameters. + /// + public static string ParametersHeader { + get { + return ResourceManager.GetString("ParametersHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} parameters. + /// + public static string ParametersPageTitle { + get { + return ResourceManager.GetString("ParametersPageTitle", resourceCulture); + } + } } } diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index 68ec2c9a96f..38595930208 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -309,4 +309,11 @@ URLs + + {0} parameters + {0} is an application name + + + Parameters + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 880397f41e5..d75b98398d0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -67,6 +67,11 @@ Metriky + + Parameters + Parameters + + Resources Prostředky diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 17f2e745db6..8029ec1ec88 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -67,6 +67,11 @@ Metriken + + Parameters + Parameters + + Resources Ressourcen diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 57535e0bf7d..e54f585ca5d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -67,6 +67,11 @@ Métricas + + Parameters + Parameters + + Resources Recursos diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index d8d38d6476f..68274ed8ddf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -67,6 +67,11 @@ Métriques + + Parameters + Parameters + + Resources Ressources diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index f6b14cb3979..c22244598bf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -67,6 +67,11 @@ Metriche + + Parameters + Parameters + + Resources Risorse diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index b3454a0180f..54e1ee13fb3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -67,6 +67,11 @@ メトリック + + Parameters + Parameters + + Resources リソース diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 89b7607a7fc..20547e1f6a8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -67,6 +67,11 @@ 메트릭 + + Parameters + Parameters + + Resources 리소스 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 12a02e3620c..ec023916224 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -67,6 +67,11 @@ Metryki + + Parameters + Parameters + + Resources Zasoby diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index f702d60e124..47b668cf248 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -67,6 +67,11 @@ Métricas + + Parameters + Parameters + + Resources Recursos diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 623d91a17f2..1169514d382 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -67,6 +67,11 @@ Метрики + + Parameters + Parameters + + Resources Ресурсы diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index e15f4e33654..396b2a84cca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -67,6 +67,11 @@ Ölçümler + + Parameters + Parameters + + Resources Kaynaklar diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 9bf9947cf95..cd3fbc92b78 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -67,6 +67,11 @@ 指标 + + Parameters + Parameters + + Resources 资源 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 659cadb432c..ad3e99e4bfa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -67,6 +67,11 @@ 計量 + + Parameters + Parameters + + Resources 資源 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf index 2eb19aa23e2..15f00e6b934 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf @@ -17,6 +17,16 @@ {0} (naposledy spuštěno v {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Příkazy diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf index bd315812c1e..ceb011ea086 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf @@ -17,6 +17,16 @@ {0} (letzte Ausführung um {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Befehle diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf index 23152fd3eef..14661711be1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf @@ -17,6 +17,16 @@ {0} (última ejecución a las {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Comandos diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf index f6c394fe892..4377ba4f2e1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf @@ -17,6 +17,16 @@ {0} (dernière exécution à {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Commandes diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf index 5412a30ec5a..91e3f78c34f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf @@ -17,6 +17,16 @@ {0} (ultima esecuzione alle {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Comandi diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf index f10dd5fdcae..30b617664ec 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf @@ -17,6 +17,16 @@ {0} ( {1} で最後に実行) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands コマンド diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf index 8802c2e100b..250f34ab18a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf @@ -17,6 +17,16 @@ {0}({1}에 마지막으로 실행) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands 명령 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf index 018c28cdb55..0f4669a609b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf @@ -17,6 +17,16 @@ {0} (ostatnio uruchomione o {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Polecenia diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf index 2f58946ef62..5178b99b6ea 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf @@ -17,6 +17,16 @@ {0} (última execução em {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Comandos diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf index eb8012b27d9..325b1b93057 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf @@ -17,6 +17,16 @@ {0} (последний запуск в {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Команды diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf index 6802d876330..49fc04f10f0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf @@ -17,6 +17,16 @@ {0} (son çalıştırma: {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands Komutlar diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf index 1a2f1d8e398..5c52a43ecd4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf @@ -17,6 +17,16 @@ {0} (上次运行时间: {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands 命令 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf index 5c0b0c5fbb4..d1f3f2bb8ad 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf @@ -17,6 +17,16 @@ {0} (上次執行時間為 {1}) {0} is the health status like "Unhealthy", {1} is the local time when the health check ran + + Parameters + Parameters + + + + {0} parameters + {0} parameters + {0} is an application name + Commands 命令 diff --git a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs index 74e7ca2b32b..7dc1de3a3b9 100644 --- a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs +++ b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs @@ -14,6 +14,7 @@ internal static class BrowserStorageKeys public const string MetricsPageState = "Aspire_PageState_Metrics"; public const string ConsoleLogsPageState = "Aspire_PageState_ConsoleLogs"; public const string ResourcesPageState = "Resources_PageState"; + public const string ParametersPageState = "Parameters_PageState"; public const string ConsoleLogConsoleSettings = "Aspire_ConsoleLog_ConsoleSettings"; public const string ConsoleLogFilters = "Aspire_ConsoleLog_Filters"; public const string ResourcesCollapsedResourceNames = "Aspire_Resources_CollapsedResourceNames"; diff --git a/src/Aspire.Dashboard/Utils/DashboardUrls.cs b/src/Aspire.Dashboard/Utils/DashboardUrls.cs index 73f530a2dee..418fcb20319 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUrls.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUrls.cs @@ -9,6 +9,7 @@ namespace Aspire.Dashboard.Utils; internal static class DashboardUrls { public const string ResourcesBasePath = ""; + public const string ParametersBasePath = "parameters"; public const string ConsoleLogBasePath = "consolelogs"; public const string MetricsBasePath = "metrics"; public const string StructuredLogsBasePath = "structuredlogs"; @@ -43,6 +44,33 @@ public static string ResourcesUrl(string? resource = null, string? view = null, return url; } + public static string ParametersUrl(string? resource = null, string? view = null, string? hiddenTypes = null, string? hiddenStates = null, string? hiddenHealthStates = null) + { + var url = $"/{ParametersBasePath}"; + if (resource != null) + { + url = QueryHelpers.AddQueryString(url, "resource", resource); + } + if (view != null) + { + url = QueryHelpers.AddQueryString(url, "view", view); + } + if (hiddenTypes != null) + { + url = QueryHelpers.AddQueryString(url, "hiddenTypes", hiddenTypes); + } + if (hiddenStates != null) + { + url = QueryHelpers.AddQueryString(url, "hiddenStates", hiddenStates); + } + if (hiddenHealthStates != null) + { + url = QueryHelpers.AddQueryString(url, "hiddenHealthStates", hiddenHealthStates); + } + + return url; + } + public static string ConsoleLogsUrl(string? resource = null) { var url = $"/{ConsoleLogBasePath}"; From f7980114ca69d4f0b1ccfead85e6725dfa44b516 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:28:12 +0000 Subject: [PATCH 3/8] Add tests for parameter filtering in Resources and Parameters pages Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com> --- .../Pages/ParametersTests.cs | 78 +++++++++++++++++++ .../Pages/ResourcesTests.cs | 34 ++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/Aspire.Dashboard.Components.Tests/Pages/ParametersTests.cs diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ParametersTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ParametersTests.cs new file mode 100644 index 00000000000..eb245984b58 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ParametersTests.cs @@ -0,0 +1,78 @@ +// 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 System.Threading.Channels; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Tests.Shared; +using Bunit; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Pages; + +[UseCulture("en-US")] +public class ParametersTests : DashboardTestContext +{ + [Fact] + public void ParametersPage_OnlyShowsParameters() + { + // Arrange + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var initialResources = new List + { + CreateResource("Resource1", "Container", "Running", null), + CreateResource("Param1", KnownResourceTypes.Parameter, "Running", null), + CreateResource("Resource2", "Project", "Running", null), + CreateResource("Param2", KnownResourceTypes.Parameter, "Running", null), + }; + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); + ResourceSetupHelpers.SetupResourcesPage( + this, + viewport, + dashboardClient); + + // Act + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + cut.WaitForState(() => cut.Instance.GetFilteredResources().Any()); + + // Assert - only parameters should be shown + var filteredResources = cut.Instance.GetFilteredResources().ToList(); + Assert.Equal(2, filteredResources.Count); + Assert.Contains(filteredResources, r => r.Name == "Param1" && r.ResourceType == KnownResourceTypes.Parameter); + Assert.Contains(filteredResources, r => r.Name == "Param2" && r.ResourceType == KnownResourceTypes.Parameter); + Assert.DoesNotContain(filteredResources, r => r.ResourceType != KnownResourceTypes.Parameter); + } + + 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 = [], + IsHidden = false, + }; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 72827521a72..8d98e4542b2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -353,6 +353,40 @@ public void ResourcesShouldRemainUnchangedWhenFilterDoesNotMatchUpdatedResource( Assert.Contains(filteredResources, r => r.Name == "Resource3"); } + [Fact] + public void ResourcesPage_ExcludesParameters() + { + // Arrange + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var initialResources = new List + { + CreateResource("Resource1", "Container", "Running", null), + CreateResource("Param1", KnownResourceTypes.Parameter, "Running", null), + CreateResource("Resource2", "Project", "Running", null), + CreateResource("Param2", KnownResourceTypes.Parameter, "Running", null), + }; + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); + ResourceSetupHelpers.SetupResourcesPage( + this, + viewport, + dashboardClient); + + // Act + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + cut.WaitForState(() => cut.Instance.GetFilteredResources().Any()); + + // Assert - parameters should be filtered out + var filteredResources = cut.Instance.GetFilteredResources().ToList(); + Assert.Equal(2, filteredResources.Count); + Assert.Contains(filteredResources, r => r.Name == "Resource1" && r.ResourceType == "Container"); + Assert.Contains(filteredResources, r => r.Name == "Resource2" && r.ResourceType == "Project"); + Assert.DoesNotContain(filteredResources, r => r.ResourceType == KnownResourceTypes.Parameter); + } + private static ResourceViewModel CreateResource(string name, string type, string? state, ImmutableArray? healthReports) { return new ResourceViewModel From 2e07cb1bfb8bdc2b9ea753a6b2e29f3161b6b1a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:58:20 +0000 Subject: [PATCH 4/8] Address code review suggestions: Fix logger type, navigation URL, and telemetry component ID Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com> --- src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs | 6 +++--- src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs index 96b191c6814..5c43ecaaf78 100644 --- a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs @@ -64,7 +64,7 @@ public partial class Parameters : ComponentBase, IComponentWithTelemetry, IAsync [Inject] public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; } [Inject] - public required ILogger Logger { get; init; } + public required ILogger Logger { get; init; } [Inject] public required IStringLocalizer AIAssistantLoc { get; init; } [Inject] @@ -576,7 +576,7 @@ protected override async Task OnParametersSetAsync() } // Navigate to remove ?resource=xxx in the URL. - NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); + NavigationManager.NavigateTo(DashboardUrls.ParametersUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); } UpdateTelemetryProperties(); @@ -943,7 +943,7 @@ private async Task ContextMenuClosed(Microsoft.AspNetCore.Components.Web.MouseEv } // IComponentWithTelemetry impl - public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.Resources); + public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.Parameters); public void UpdateTelemetryProperties() { diff --git a/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs b/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs index c1e1651c27e..c646c99b847 100644 --- a/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs +++ b/src/Aspire.Dashboard/Telemetry/TelemetryComponentIds.cs @@ -7,6 +7,7 @@ public static class TelemetryComponentIds { // Pages public const string Resources = nameof(Resources); + public const string Parameters = nameof(Parameters); public const string Login = nameof(Login); public const string Traces = nameof(Traces); public const string StructuredLogs = nameof(StructuredLogs); From 333ba694e41acec9d3f97c906b3175ebaa14d2cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:06:26 +0000 Subject: [PATCH 5/8] Remove duplicated CSS file - Parameters page inherits styles from Resources.razor.css Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> --- .../Components/Pages/Parameters.razor.css | 194 ------------------ 1 file changed, 194 deletions(-) delete mode 100644 src/Aspire.Dashboard/Components/Pages/Parameters.razor.css diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.css b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.css deleted file mode 100644 index 643547fecb2..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.css +++ /dev/null @@ -1,194 +0,0 @@ -::deep .unread-logs-errors-link { - vertical-align: super; - --unread-logs-badge-color: #ffffff; -} - -::deep .unread-logs-errors-link::part(control) { - margin-left: 5px; - padding: 0; - flex-grow: 0; - color: #ffffff; -} - -::deep .error-counter-badge { - padding: 1px; -} - -/* Forces the grid to always take up the width of its rows, - and the rows to always take up the width of the cells. - This fixes a few odd rendering things, but also enables us - to set overflow-x: clip on the grid to prevent an odd visual - glitch with the menu when it is at the far right of the viewport -*/ -::deep .fluent-data-grid, -::deep .fluent-data-grid-row { - min-width: min-content; -} - -/* Ensures that any popups (e.g. menus) don't overflow past - the right edge of the grid causing a visual flicker of - the horizontal scrollbar appearing and disappearing -*/ -::deep .fluent-data-grid { - overflow-x: clip; -} - -::deep .resources-name-container { - height: 24px; - display: inline-flex; - vertical-align: middle; - align-items: center; -} - -::deep tr.resource-row > :first-child { - padding-inline-start: 0; -} - -::deep .resources-summary-layout { - display: grid; - grid-template-rows: 1fr auto; - height: 100%; - width: 100%; - grid-template-areas: - "main" - "foot"; -} - -::deep .resource-tabs { - grid-area: main; - display: grid; - grid-template-areas: - "resources-tab-header" - "resources-tab-content"; - grid-template-rows: auto 1fr; - overflow: hidden; -} - -::deep .resource-tabs fluent-tabs { - margin-left: calc(var(--design-unit) * 3px); -} - -::deep .resources-grid-container { - overflow: auto; - grid-area: resources-tab-content; -} - -::deep .resource-tabs .resources-grid-container { - margin-top: 10px; -} - -::deep .resource-graph-container { - grid-area: resources-tab-content; - position: relative; /* So graph buttons are position inside the container */ -} - -::deep .resource-graph-controls { - position: absolute; - right: 30px; - bottom: 30px; - display: flex; - column-gap: 10px; -} - -::deep .resource-graph .texts { - cursor: pointer; -} - -::deep .resource-graph .nodes { - cursor: pointer; -} - -::deep .resource-name { - fill: var(--neutral-foreground-rest); - stroke: var(--fill-color); - font-size: 15px; - text-anchor: middle; - stroke-width: 0.5em; - paint-order: stroke; - stroke-linejoin: round; -} - -::deep .resource-node { - stroke: var(--fill-color); - fill: var(--fill-color); -} - -::deep .resource-group-hover .resource-node { - fill: var(--neutral-fill-hover) !important; -} - -::deep .resource-group-selected .resource-scale { - transform: scale(1.2); -} - -::deep .resource-group-selected .resource-node { - fill: var(--neutral-fill-secondary-rest) !important; -} - -::deep .resource-group-selected .resource-node-border { - stroke: var(--neutral-stroke-hover); -} - -::deep .resource-group-highlight .resource-node { - fill: url(#highlighted-pattern); -} - -::deep .resource-group-highlight .resource-node-border { - stroke: var(--neutral-stroke-hover); -} - -::deep .resource-group-hover .resource-node-border { - stroke: var(--neutral-stroke-hover); -} - -::deep .resource-node-border { - stroke: var(--neutral-stroke-rest); - stroke-width: 1; - fill: transparent; -} - -::deep .resource-endpoint { - fill: var(--foreground-subtext-rest); - font-size: 11px; - text-anchor: middle; -} - -::deep .resource-status-circle { - fill: var(--fill-color); -} - -::deep .resource-link { - stroke: var(--neutral-stroke-rest); - stroke-width: 1; - marker-end: url(#arrow-normal); -} - -::deep .arrow-normal { - fill: var(--neutral-stroke-rest); -} - -::deep .arrow-highlight { - fill: var(--neutral-stroke-hover); -} - -::deep .arrow-highlight-expand { - fill: var(--neutral-stroke-hover); -} - -::deep .resource-link-highlight { - stroke: var(--neutral-stroke-hover); - stroke-dasharray: 5,5; - stroke-width: 2; - marker-end: url(#arrow-highlight); -} - -::deep .resource-link-highlight-expand { - stroke: var(--neutral-stroke-hover); - stroke-dasharray: 5,5; - stroke-width: 2; - marker-end: url(#arrow-highlight-expand); -} - -::deep .tab-label > svg { - margin-right: calc(var(--design-unit) * 1px); -} From 692c6b4b9ad05e597f8c0b196dd6ac652b8a1cae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Oct 2025 21:27:11 +0000 Subject: [PATCH 6/8] Refactor: Move Parameters as 3rd tab inside Resources page instead of separate page Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> --- .../Components/Layout/DesktopNavMenu.razor | 5 - .../Components/Layout/DesktopNavMenu.razor.cs | 4 - .../Components/Layout/MobileNavMenu.razor.cs | 7 - .../Components/Pages/Parameters.razor | 246 ----- .../Components/Pages/Parameters.razor.cs | 958 ------------------ .../Components/Pages/Resources.razor | 7 +- .../Components/Pages/Resources.razor.cs | 24 +- .../Resources/Layout.Designer.cs | 6 - src/Aspire.Dashboard/Resources/Layout.resx | 3 - .../Resources/Resources.Designer.cs | 9 + src/Aspire.Dashboard/Resources/Resources.resx | 3 + .../Resources/xlf/Layout.cs.xlf | 5 - .../Resources/xlf/Layout.de.xlf | 5 - .../Resources/xlf/Layout.es.xlf | 5 - .../Resources/xlf/Layout.fr.xlf | 5 - .../Resources/xlf/Layout.it.xlf | 5 - .../Resources/xlf/Layout.ja.xlf | 5 - .../Resources/xlf/Layout.ko.xlf | 5 - .../Resources/xlf/Layout.pl.xlf | 5 - .../Resources/xlf/Layout.pt-BR.xlf | 5 - .../Resources/xlf/Layout.ru.xlf | 5 - .../Resources/xlf/Layout.tr.xlf | 5 - .../Resources/xlf/Layout.zh-Hans.xlf | 5 - .../Resources/xlf/Layout.zh-Hant.xlf | 5 - .../Resources/xlf/Resources.cs.xlf | 5 + .../Resources/xlf/Resources.de.xlf | 5 + .../Resources/xlf/Resources.es.xlf | 5 + .../Resources/xlf/Resources.fr.xlf | 5 + .../Resources/xlf/Resources.it.xlf | 5 + .../Resources/xlf/Resources.ja.xlf | 5 + .../Resources/xlf/Resources.ko.xlf | 5 + .../Resources/xlf/Resources.pl.xlf | 5 + .../Resources/xlf/Resources.pt-BR.xlf | 5 + .../Resources/xlf/Resources.ru.xlf | 5 + .../Resources/xlf/Resources.tr.xlf | 5 + .../Resources/xlf/Resources.zh-Hans.xlf | 5 + .../Resources/xlf/Resources.zh-Hant.xlf | 5 + .../Utils/BrowserStorageKeys.cs | 1 - src/Aspire.Dashboard/Utils/DashboardUrls.cs | 28 - .../Pages/ParametersTests.cs | 78 -- 40 files changed, 104 insertions(+), 1405 deletions(-) delete mode 100644 src/Aspire.Dashboard/Components/Pages/Parameters.razor delete mode 100644 src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs delete mode 100644 tests/Aspire.Dashboard.Components.Tests/Pages/ParametersTests.cs diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor index c80b100c167..b5b78e8f5ef 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor @@ -12,11 +12,6 @@ IconRest="ResourcesIcon()" IconActive="ResourcesIcon(active: true)" Text="@Loc[nameof(Layout.NavMenuResourcesTab)]" /> - active ? new Icons.Filled.Size24.AppFolder() : new Icons.Regular.Size24.AppFolder(); - internal static Icon ParametersIcon(bool active = false) => - active ? new Icons.Filled.Size24.Key() - : new Icons.Regular.Size24.Key(); - internal static Icon ConsoleLogsIcon(bool active = false) => active ? new Icons.Filled.Size24.SlideText() : new Icons.Regular.Size24.SlideText(); diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index afff2da59a8..42ab3f27090 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -45,13 +45,6 @@ private IEnumerable GetMobileNavMenuEntries() LinkMatchRegex: new Regex($"^{DashboardUrls.ResourcesUrl()}(\\?.*)?$") ); - yield return new MobileNavMenuEntry( - Loc[nameof(Resources.Layout.NavMenuParametersTab)], - () => NavigateToAsync(DashboardUrls.ParametersUrl()), - DesktopNavMenu.ParametersIcon(), - LinkMatchRegex: GetNonIndexPageRegex(DashboardUrls.ParametersUrl()) - ); - yield return new MobileNavMenuEntry( Loc[nameof(Resources.Layout.NavMenuConsoleLogsTab)], () => NavigateToAsync(DashboardUrls.ConsoleLogsUrl()), diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor b/src/Aspire.Dashboard/Components/Pages/Parameters.razor deleted file mode 100644 index a170fcf4c19..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/Parameters.razor +++ /dev/null @@ -1,246 +0,0 @@ -@page "/parameters" -@using Aspire.Dashboard.Components.ResourcesGridColumns -@using Aspire.Dashboard.Resources -@using Aspire.Dashboard.Utils -@using System.Globalization -@using Aspire.Dashboard.Components.Controls.Grid -@using Aspire.Dashboard.Model -@using Humanizer -@inject IStringLocalizer Loc -@inject IStringLocalizer ControlsStringsLoc -@inject IStringLocalizer ColumnsLoc -@inject IStringLocalizer CommandsLoc - - - -@{ - var showDetailsView = SelectedResource is not null; -} - -
- - -

@Loc[nameof(Dashboard.Resources.Resources.ParametersHeader)]

-
- - - - - - - @if (ViewportInformation.IsDesktop) - { - - - @if (HasAnyChildResources()) - { - - } - } - else - { - foreach (var item in _resourcesMenuItems) - { - - @item.Text - - } - -
- -
- } -
- - - - - - - - - - -
-
- @* - Tab content isn't nested inside FluentTab elements. The tab control is just used to display the tabs. - Content is located in manually created divs so they can be placed in their own CSS grid row. - *@ - @if (!_hideResourceGraph) - { - - - - - - - } - - @if (!_hideResourceGraph) - { - - } -
-
- - -
-
- -
-
-
- - @* Don't display footer with the resource graph *@ - - -
-
diff --git a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs b/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs deleted file mode 100644 index 5c43ecaaf78..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/Parameters.razor.cs +++ /dev/null @@ -1,958 +0,0 @@ -// 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.Configuration; -using Aspire.Dashboard.Extensions; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Model.Assistant; -using Aspire.Dashboard.Model.ResourceGraph; -using Aspire.Dashboard.Otlp.Storage; -using Aspire.Dashboard.Telemetry; -using Aspire.Dashboard.Utils; -using Aspire.Hosting.Utils; -using Humanizer; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Options; -using Microsoft.FluentUI.AspNetCore.Components; -using Microsoft.JSInterop; -using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; - -namespace Aspire.Dashboard.Components.Pages; - -public partial class Parameters : ComponentBase, IComponentWithTelemetry, IAsyncDisposable, IPageWithSessionAndUrlState -{ - 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 UrlsColumn = nameof(UrlsColumn); - private const string ActionsColumn = nameof(ActionsColumn); - - private Subscription? _logsSubscription; - private IList? _gridColumns; - private EventCallback _onToggleCollapseAllCallback; - private EventCallback _onToggleResourceTypeCallback; - private bool _hideResourceGraph; - private Dictionary? _resourceUnviewedErrorCounts; - - [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; } - [Inject] - public required IAIContextProvider AIContextProvider { get; init; } - [Inject] - public required IOptionsMonitor DashboardOptions { get; init; } - [Inject] - public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; } - [Inject] - public required ILogger Logger { get; init; } - [Inject] - public required IStringLocalizer AIAssistantLoc { get; init; } - [Inject] - public required IStringLocalizer AIPromptsLoc { get; init; } - [Inject] - public required IconResolver IconResolver { get; init; } - - public string BasePath => DashboardUrls.ParametersBasePath; - public string SessionStorageKey => BrowserStorageKeys.ParametersPageState; - public ParametersViewModel PageViewModel { get; set; } = null!; - - [Parameter] - [SupplyParameterFromQuery(Name = "view")] - public string? ViewKindName { get; set; } - - [Parameter] - [SupplyParameterFromQuery(Name = "showHiddenResources")] - public bool ShowHiddenResources { get; set; } - - [CascadingParameter] - public required ViewportInformation ViewportInformation { get; set; } - - [Parameter] - [SupplyParameterFromQuery] - public string? HiddenTypes { get; set; } - - [Parameter] - [SupplyParameterFromQuery] - public string? HiddenStates { get; set; } - - [Parameter] - [SupplyParameterFromQuery] - public string? HiddenHealthStates { get; set; } - - [Parameter] - [SupplyParameterFromQuery(Name = "resource")] - public string? ResourceName { get; set; } - - private ResourceViewModel? SelectedResource { get; set; } - - private readonly CancellationTokenSource _watchTaskCancellationTokenSource = new(); - private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); - private readonly HashSet _collapsedResourceNames = new(StringComparers.ResourceName); - private readonly TaskCompletionSource _loadingTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - private string _filter = ""; - private bool _isFilterPopupVisible; - private Task? _resourceSubscriptionTask; - private string? _elementIdBeforeDetailsViewOpened; - private FluentDataGrid _dataGrid = null!; - private GridColumnManager _manager = null!; - private int _maxHighlightedCount; - private readonly List _resourcesMenuItems = new(); - private DotNetObjectReference? _resourcesInteropReference; - private IJSObjectReference? _jsModule; - private bool _graphInitialized; - private AspirePageContentLayout? _contentLayout; - private TotalItemsFooter _totalItemsFooter = default!; - private int _totalItemsCount; - - private AspireMenu? _contextMenu; - private bool _contextMenuOpen; - private readonly List _contextMenuItems = new(); - private TaskCompletionSource? _contextMenuClosedTcs; - - private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; - private ColumnSortLabels _sortLabels = ColumnSortLabels.Default; - private bool _showResourceTypeColumn; - private AIContext? _aiContext; - private bool _showHiddenResources; - - private bool Filter(ResourceViewModel resource) - { - return IsKeyValueTrue(resource.ResourceType, PageViewModel.ResourceTypesToVisibility) - && IsKeyValueTrue(resource.State ?? string.Empty, PageViewModel.ResourceStatesToVisibility) - && IsKeyValueTrue(resource.HealthStatus?.Humanize() ?? string.Empty, PageViewModel.ResourceHealthStatusesToVisibility) - && (_filter.Length == 0 || resource.MatchesFilter(_filter)) - && !resource.IsResourceHidden(_showHiddenResources) - && StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Parameter); - - static bool IsKeyValueTrue(string key, IDictionary dictionary) => dictionary.TryGetValue(key, out var value) && value; - } - - private async Task OnAllFilterVisibilityCheckedChangedAsync() - { - await ClearSelectedResourceAsync(); - await _dataGrid.SafeRefreshDataAsync(); - UpdateMenuButtons(); - await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false); - } - - private async Task OnResourceFilterVisibilityChangedAsync(string resourceType, bool isVisible) - { - await UpdateResourceGraphResourcesAsync(); - await ClearSelectedResourceAsync(); - await _dataGrid.SafeRefreshDataAsync(); - UpdateMenuButtons(); - await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false); - } - - private async Task HandleSearchFilterChangedAsync() - { - await UpdateResourceGraphResourcesAsync(); - await ClearSelectedResourceAsync(); - await _dataGrid.SafeRefreshDataAsync(); - } - - // Internal for tests - internal bool NoFiltersSet => AreAllTypesVisible && AreAllStatesVisible && AreAllHealthStatesVisible; - internal bool AreAllTypesVisible => PageViewModel.ResourceTypesToVisibility.Values.All(value => value); - internal bool AreAllStatesVisible => PageViewModel.ResourceStatesToVisibility.Values.All(value => value); - internal bool AreAllHealthStatesVisible => PageViewModel.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); - private readonly GridSort _typeSort = GridSort.ByAscending(p => p.Resource.ResourceType).ThenAscending(p => p.Resource, ResourceViewModelNameComparer.Instance); - - protected override async Task OnInitializedAsync() - { - TelemetryContextProvider.Initialize(TelemetryContext); - _aiContext = AIContextProvider.AddNew(nameof(Parameters), c => - { - c.BuildIceBreakers = (builder, context) => - { - var hasUnhealthyResources = _resourceByName.Values - .Where(r => !r.IsResourceHidden(_showHiddenResources)) - .Any(r => r.KnownState != KnownResourceState.Running || r.HealthStatus is HealthStatus.Unhealthy or HealthStatus.Degraded); - - builder.Resources(context, hasUnhealthyResources); - }; - }); - - (_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", IsVisible: () => _showResourceTypeColumn), - new GridColumn(Name: SourceColumn, DesktopWidth: "2.25fr"), - new GridColumn(Name: UrlsColumn, DesktopWidth: "2.25fr", MobileWidth: "2fr"), - new GridColumn(Name: ActionsColumn, DesktopWidth: "minmax(150px, 1.5fr)", MobileWidth: "1fr") - ]; - - _onToggleCollapseAllCallback = EventCallback.Factory.Create(this, OnToggleCollapseAll); - _onToggleResourceTypeCallback = EventCallback.Factory.Create(this, OnToggleResourceType); - - _hideResourceGraph = DashboardOptions.CurrentValue.UI.DisableResourceGraph ?? false; - - PageViewModel = new ParametersViewModel - { - SelectedViewKind = ResourceViewKind.Table - }; - - _resourceUnviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount(); - - var showResourceTypeColumn = await SessionStorage.GetAsync(BrowserStorageKeys.ResourcesShowResourceTypes); - if (showResourceTypeColumn.Success) - { - _showResourceTypeColumn = showResourceTypeColumn.Value; - } - - var showHiddenResources = await SessionStorage.GetAsync(BrowserStorageKeys.ResourcesShowHiddenResources); - if (showHiddenResources.Success) - { - _showHiddenResources = showHiddenResources.Value; - } - UpdateMenuButtons(); - - if (DashboardClient.IsEnabled) - { - var collapsedResult = await SessionStorage.GetAsync>(BrowserStorageKeys.ResourcesCollapsedResourceNames); - if (collapsedResult.Success) - { - foreach (var resourceName in collapsedResult.Value) - { - _collapsedResourceNames.Add(resourceName); - } - } - - await SubscribeResourcesAsync(); - } - - _logsSubscription = TelemetryRepository.OnNewLogs(null, SubscriptionType.Other, async () => - { - var newResourceUnviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount(); - - // Only update UI if the error counts have changed. - if (ResourceErrorCountsChanged(newResourceUnviewedErrorCounts)) - { - _resourceUnviewedErrorCounts = newResourceUnviewedErrorCounts; - await InvokeAsync(_dataGrid.SafeRefreshDataAsync); - } - }); - - _loadingTcs.SetResult(); - - async Task SubscribeResourcesAsync() - { - var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_watchTaskCancellationTokenSource.Token); - - // Apply snapshot. - foreach (var resource in snapshot) - { - var added = UpdateFromResource(resource); - 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, - // The new type/state/health status should be visible if it's either - // 1) new, or - // 2) previously visible - t => !PageViewModel.ResourceTypesToVisibility.TryGetValue(t, out var value) || value, - s => !PageViewModel.ResourceStatesToVisibility.TryGetValue(s, out var value) || value, - s => !PageViewModel.ResourceHealthStatusesToVisibility.TryGetValue(s, out var value) || value); - - 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(); - _aiContext?.ContextHasChanged(); - 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(); - } - }); - } - }); - } - } - - private bool UpdateFromResource(ResourceViewModel resource) - { - var preselectedHiddenResourceTypes = HiddenTypes?.Split(' ').Select(StringUtils.Unescape).ToHashSet(); - var preselectedHiddenResourceStates = HiddenStates?.Split(' ').Select(StringUtils.Unescape).ToHashSet(); - var preselectedHiddenResourceHealthStates = HiddenHealthStates?.Split(' ').Select(StringUtils.Unescape).ToHashSet(); - - return UpdateFromResource( - resource, - type => preselectedHiddenResourceTypes is null || !preselectedHiddenResourceTypes.Contains(type), - state => preselectedHiddenResourceStates is null || !preselectedHiddenResourceStates.Contains(state), - healthStatus => preselectedHiddenResourceHealthStates is null || !preselectedHiddenResourceHealthStates.Contains(healthStatus)); - } - - private bool UpdateFromResource(ResourceViewModel resource, Func resourceTypeVisible, Func stateVisible, Func 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); - } - - PageViewModel.ResourceTypesToVisibility.AddOrUpdate(resource.ResourceType, resourceTypeVisible(resource.ResourceType), (_, _) => resourceTypeVisible(resource.ResourceType)); - PageViewModel.ResourceStatesToVisibility.AddOrUpdate(resource.State ?? string.Empty, stateVisible(resource.State ?? string.Empty), (_, _) => stateVisible(resource.State ?? string.Empty)); - PageViewModel.ResourceHealthStatusesToVisibility.AddOrUpdate(resource.HealthStatus?.Humanize() ?? string.Empty, healthStatusVisible(resource.HealthStatus?.Humanize() ?? string.Empty), (_, _) => healthStatusVisible(resource.HealthStatus?.Humanize() ?? string.Empty)); - - UpdateMenuButtons(); - - return added; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - // Check to see whether max item count should be set on every render. - // This is required because the data grid's virtualize component can be recreated on data change. - if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) - { - StateHasChanged(); - } - - if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph && !_graphInitialized) - { - // Before any awaits, set a flag to indicate the graph is initialized. This prevents the graph being initialized multiple times. - _graphInitialized = true; - - _jsModule = await JS.InvokeAsync("import", "/js/app-resourcegraph.js"); - - _resourcesInteropReference = DotNetObjectReference.Create(new ParametersInterop(this)); - - await _jsModule.InvokeVoidAsync("initializeResourcesGraph", _resourcesInteropReference); - await UpdateResourceGraphResourcesAsync(); - await UpdateResourceGraphSelectedAsync(); - } - } - - 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, _showHiddenResources, IconResolver)).ToList(); - await _jsModule.InvokeVoidAsync("updateResourcesGraph", resources); - } - - private class ParametersInterop(Parameters parameters) - { - [JSInvokable] - public async Task SelectResource(string id) - { - if (parameters._resourceByName.TryGetValue(id, out var resource)) - { - await parameters.InvokeAsync(async () => - { - await parameters.ShowResourceDetailsAsync(resource, null!); - parameters.StateHasChanged(); - }); - } - } - - [JSInvokable] - public async Task ResourceContextMenu(string id, int screenWidth, int screenHeight, int clientX, int clientY) - { - if (parameters._resourceByName.TryGetValue(id, out var resource)) - { - await parameters.InvokeAsync(async () => - { - await parameters.ShowContextMenuAsync(resource, screenWidth, screenHeight, clientX, clientY); - }); - } - } - } - - internal IEnumerable GetFilteredResources() - { - return _resourceByName - .Values - .Where(Filter); - } - - private ValueTask> GetData(GridItemsProviderRequest 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(); - - _totalItemsCount = orderedResources.Count; - _totalItemsFooter.UpdateDisplayedCount(query.Count); - - return ValueTask.FromResult(GridItemsProviderResult.From(query, orderedResources.Count)); - } - - private void UpdateMenuButtons() - { - _resourcesMenuItems.Clear(); - - if (HasCollapsedResources()) - { - _resourcesMenuItems.Add(new MenuButtonItem - { - IsDisabled = false, - OnClick = _onToggleCollapseAllCallback.InvokeAsync, - Text = Loc[nameof(Dashboard.Resources.Resources.ResourceExpandAllChildren)], - Icon = new Icons.Regular.Size16.Eye() - }); - } - else - { - _resourcesMenuItems.Add(new MenuButtonItem - { - IsDisabled = false, - OnClick = _onToggleCollapseAllCallback.InvokeAsync, - Text = Loc[nameof(Dashboard.Resources.Resources.ResourceCollapseAllChildren)], - Icon = new Icons.Regular.Size16.EyeOff() - }); - } - - if (_showResourceTypeColumn) - { - _resourcesMenuItems.Add(new MenuButtonItem - { - IsDisabled = false, - OnClick = _onToggleResourceTypeCallback.InvokeAsync, - Text = Loc[nameof(Dashboard.Resources.Resources.ResourcesHideTypes)], - Icon = new Icons.Regular.Size16.EyeOff() - }); - } - else - { - _resourcesMenuItems.Add(new MenuButtonItem - { - IsDisabled = false, - OnClick = _onToggleResourceTypeCallback.InvokeAsync, - Text = Loc[nameof(Dashboard.Resources.Resources.ResourcesShowTypes)], - Icon = new Icons.Regular.Size16.Eye() - }); - } - - CommonMenuItems.AddToggleHiddenResourcesMenuItem( - _resourcesMenuItems, - ControlsStringsLoc, - _showHiddenResources, - _resourceByName.Values, - SessionStorage, - EventCallback.Factory.Create(this, - async value => - { - _showHiddenResources = value; - UpdateMenuButtons(); - await _dataGrid.SafeRefreshDataAsync(); - })); - } - - private bool HasCollapsedResources() - { - return _resourceByName.Any(r => !r.Value.IsResourceHidden(_showHiddenResources) && _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; - } - - // Wait until the initial data is loaded. This is required so there isn't a race between data loading and using resources here. - await _loadingTcs.Task; - - // If filters were saved in page state, resource filters now need to be recomputed since the URL has changed. - foreach (var resourceViewModel in _resourceByName) - { - UpdateFromResource(resourceViewModel.Value); - } - - if (ResourceName is not null) - { - if (_resourceByName.TryGetValue(ResourceName, out var selectedResource)) - { - await ShowResourceDetailsAsync(selectedResource, buttonId: null); - } - else - { - Logger.LogDebug("Can't navigate to {ResourceName} from URL. Resource not found.", ResourceName); - } - - // Navigate to remove ?resource=xxx in the URL. - NavigationManager.NavigateTo(DashboardUrls.ParametersUrl(), new NavigationOptions { ReplaceHistoryEntry = true }); - } - - UpdateTelemetryProperties(); - } - - private bool ResourceErrorCountsChanged(Dictionary newResourceUnviewedErrorCounts) - { - if (_resourceUnviewedErrorCounts == null || _resourceUnviewedErrorCounts.Count != newResourceUnviewedErrorCounts.Count) - { - return true; - } - - foreach (var (resource, count) in newResourceUnviewedErrorCounts) - { - if (!_resourceUnviewedErrorCounts.TryGetValue(resource, out var oldCount) || oldCount != count) - { - return true; - } - } - - return false; - } - - private async Task ShowContextMenuAsync(ResourceViewModel resource, int screenWidth, int screenHeight, int clientX, int clientY) - { - // This is called when the browser requests to show the context menu for a resource. - // The method doesn't complete until the context menu is closed so the browser can await - // it and perform clean up when the context menu is closed. - if (_contextMenu is { } contextMenu) - { - _contextMenuItems.Clear(); - ResourceMenuItems.AddMenuItems( - _contextMenuItems, - resource, - NavigationManager, - TelemetryRepository, - AIContextProvider, - GetResourceName, - ControlsStringsLoc, - Loc, - AIAssistantLoc, - AIPromptsLoc, - CommandsLoc, - EventCallback.Factory.Create(this, () => ShowResourceDetailsAsync(resource, buttonId: null)), - EventCallback.Factory.Create(this, (command) => ExecuteResourceCommandAsync(resource, command)), - (resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name), - showConsoleLogsItem: true, - showUrls: true, - IconResolver); - - // The previous context menu should always be closed by this point but complete just in case. - _contextMenuClosedTcs?.TrySetResult(); - - _contextMenuClosedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - await contextMenu.OpenAsync(screenWidth, screenHeight, clientX, clientY); - StateHasChanged(); - - // Completed when the overlay closes. - await _contextMenuClosedTcs.Task; - } - } - - private async Task ShowResourceDetailsAsync(ResourceViewModel resource, string? buttonId) - { - Logger.LogDebug("Showing details for resource {ResourceName}.", resource.Name); - - _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; - } - - if (PageViewModel.SelectedViewKind == ResourceViewKind.Graph) - { - await UpdateResourceGraphSelectedAsync(); - } - - await _dataGrid.SafeRefreshDataAsync(); - } - } - - private async Task ClearSelectedResourceAsync(bool causedByUserAction = false) - { - Logger.LogDebug("Clearing selected resource."); - - 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, _showHiddenResources); - - private bool HasMultipleReplicas(ResourceViewModel resource) - { - var count = 0; - foreach (var (_, item) in _resourceByName) - { - if (item.IsResourceHidden(_showHiddenResources)) - { - 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 GetUrlsTooltip(ResourceViewModel resource) - { - var displayedUrls = GetDisplayedUrls(resource); - - if (displayedUrls.Count == 0) - { - return string.Empty; - } - - if (displayedUrls.Count == 1) - { - return displayedUrls[0].Text; - } - - var maxShownUrls = 3; - var tooltipBuilder = new StringBuilder(string.Join(", ", displayedUrls.Take(maxShownUrls).Select(url => url.Text))); - - if (displayedUrls.Count > maxShownUrls) - { - tooltipBuilder.Append(CultureInfo.CurrentCulture, $" + {displayedUrls.Count - maxShownUrls}"); - } - - 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.IsResourceHidden(_showHiddenResources)) - .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 async Task OnToggleResourceType() - { - _showResourceTypeColumn = !_showResourceTypeColumn; - await SessionStorage.SetAsync(BrowserStorageKeys.ResourcesShowResourceTypes, _showResourceTypeColumn); - await _dataGrid.SafeRefreshDataAsync(); - UpdateMenuButtons(); - } - - private static List GetDisplayedUrls(ResourceViewModel resource) - { - return ResourceUrlHelpers.GetUrls(resource, includeInternalUrls: false, includeNonEndpointUrls: true); - } - - 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 - || PageViewModel.SelectedViewKind == 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 ParametersViewModel - { - public required ResourceViewKind SelectedViewKind { get; set; } - public ConcurrentDictionary ResourceTypesToVisibility { get; } = new(StringComparers.ResourceName); - public ConcurrentDictionary ResourceStatesToVisibility { get; } = new(StringComparers.ResourceState); - public ConcurrentDictionary ResourceHealthStatusesToVisibility { get; } = new(StringComparer.Ordinal); - } - - public class ParametersPageState - { - public required string? ViewKind { get; set; } - public required IDictionary ResourceTypesToVisibility { get; set; } - public required IDictionary ResourceStatesToVisibility { get; set; } - public required IDictionary ResourceHealthStatusesToVisibility { get; set; } - } - - public enum ResourceViewKind - { - Table, - Graph - } - - public Task UpdateViewModelFromQueryAsync(ParametersViewModel viewModel) - { - // Don't allow the view to be updated from the query string if the resource graph is disabled. - if (!_hideResourceGraph && Enum.TryParse(typeof(ResourceViewKind), ViewKindName, out var view) && view is ResourceViewKind vk) - { - viewModel.SelectedViewKind = vk; - } - - return Task.CompletedTask; - } - - public string GetUrlFromSerializableViewModel(ParametersPageState serializable) - { - return DashboardUrls.ParametersUrl( - view: serializable.ViewKind, - // add resource? - hiddenTypes: SerializeFiltersToString(serializable.ResourceTypesToVisibility), - hiddenStates: SerializeFiltersToString(serializable.ResourceStatesToVisibility), - hiddenHealthStates: SerializeFiltersToString(serializable.ResourceHealthStatusesToVisibility)); - - static string? SerializeFiltersToString(IDictionary filters) - { - var escapedFilters = filters.Where(kvp => !kvp.Value).Select(kvp => StringUtils.Escape(kvp.Key)).ToList(); - return escapedFilters.Count == 0 ? null : string.Join(" ", escapedFilters); - } - } - - public ParametersPageState ConvertViewModelToSerializable() - { - return new ParametersPageState - { - ViewKind = PageViewModel.SelectedViewKind != ResourceViewKind.Table ? PageViewModel.SelectedViewKind.ToString() : null, - ResourceTypesToVisibility = PageViewModel.ResourceTypesToVisibility, - ResourceStatesToVisibility = PageViewModel.ResourceStatesToVisibility, - ResourceHealthStatusesToVisibility = PageViewModel.ResourceHealthStatusesToVisibility - }; - } - - public async ValueTask DisposeAsync() - { - _aiContext?.Dispose(); - - _resourcesInteropReference?.Dispose(); - _watchTaskCancellationTokenSource.Cancel(); - _logsSubscription?.Dispose(); - TelemetryContext.Dispose(); - await JSInteropHelpers.SafeDisposeAsync(_jsModule); - - await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask); - } - - private async Task ContextMenuClosed(Microsoft.AspNetCore.Components.Web.MouseEventArgs args) - { - if (_contextMenu is { } menu) - { - await menu.CloseAsync(); - } - - _contextMenuClosedTcs?.TrySetResult(); - _contextMenuClosedTcs = null; - } - - // IComponentWithTelemetry impl - public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.Parameters); - - public void UpdateTelemetryProperties() - { - var properties = new List - { - new(TelemetryPropertyKeys.ResourceView, new AspireTelemetryProperty(PageViewModel.SelectedViewKind.ToString(), AspireTelemetryPropertyType.UserSetting)), - new(TelemetryPropertyKeys.ResourceTypes, new AspireTelemetryProperty(_resourceByName.Values.Select(r => TelemetryPropertyValues.GetResourceTypeTelemetryValue(r.ResourceType, r.SupportsDetailedTelemetry)).OrderBy(t => t).ToList())) - }; - - TelemetryContext.UpdateTelemetryProperties(properties.ToArray(), Logger); - } -} diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index d89e90e7c55..381f42cd069 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -115,9 +115,14 @@ Label="@ControlsStringsLoc[nameof(ControlsStrings.ChartContainerGraphTab)]" Icon="@(new Icons.Regular.Size24.ShareAndroid())"> + + } -