// 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 Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
using Humanizer;
using System.Globalization;
using System.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
using ResourcesPage = Aspire.Dashboard.Components.Pages.Resources;
namespace Aspire.Dashboard.Components.Layout;
/// <summary>
/// Layout for the resource-centric pages (overview, console logs, structured logs, traces and metrics).
/// It hosts the resource list on the left, and the selected resource's header and tabs above the page.
/// </summary>
/// <remarks>
/// Blazor keeps a layout instance alive while navigating between pages that share it, so the resource list,
/// its scroll position, filters and expanded state survive switching tabs or resources. The selected resource
/// and tab are derived from the current URL, which keeps every view addressable and lets the browser's
/// back/forward buttons work as expected.
/// </remarks>
public sealed partial class ResourcesLayout : LayoutComponentBase, IAsyncDisposable
{
private const string FilterButtonId = "resourcePaneFilterButton";
private const string GroupingButtonId = "resourcePaneGroupingButton";
internal const int MinimumPaneWidthPx = 200;
internal const int MaximumPaneWidthPx = 560;
internal const int DefaultPaneWidthPx = 290;
private const string ResourcesSectionKey = "section:resources";
private const string ParametersSectionKey = "section:parameters";
private const string TelemetrySectionKey = "section:telemetry";
private readonly ConcurrentDictionary<string, ResourceViewModel> _resourceByName = new(StringComparers.ResourceName);
private readonly HashSet<string> _collapsedResourceNames = new(StringComparers.ResourceName);
private readonly HashSet<string> _collapsedGroups = new(StringComparer.OrdinalIgnoreCase);
private ResourcePaneGrouping _grouping;
private readonly CancellationTokenSource _cts = new();
private readonly List<MenuButtonItem> _paneMenuItems = [];
private readonly ResourcesPage.ResourcesViewModel _filter = new() { SelectedViewKind = ResourcesPage.ResourceViewKind.Table };
private Task? _resourceSubscriptionTask;
private Task? _ageRefreshTask;
private Subscription? _logsSubscription;
private Subscription? _telemetryResourcesSubscription;
private Dictionary<ResourceKey, int>? _unviewedErrorCounts;
private List<TelemetryOnlyResource> _telemetryOnlyResources = [];
private string? _collapsedResourceNamesKey;
private bool _isLoaded;
private bool _isPaneCollapsed;
private bool _isDrawerOpen;
private bool _showTelemetryOnlyResources;
// Which kinds of items the pane lists, and so what "all resources" covers. Telemetry sources use
// _showTelemetryOnlyResources, which replaced the filter menu's telemetry-only option.
private bool _showResources = true;
private bool _showParameters = true;
private bool _pendingLandingRedirect;
private bool _hasLocation;
private string? _lastLocation;
// The tab a click on the tab strip is navigating to, until the new location arrives. See OnTabChanged.
private ResourceTab? _navigatingToTab;
private bool _isDisposed;
private IReadOnlyList<string> _selectedResourceNames = [];
private List<SelectedResourceItem> _selectedItems = [];
private string? _selectionAnchor;
private int? _paneWidth;
private ElementReference _layoutElement;
private IJSObjectReference? _jsModule;
private bool _isTabOverflowRegistered;
private string[] _overflowTabIds = [];
private DotNetObjectReference<ResourcesLayout>? _selfRef;
private bool _isResizerRegistered;
private bool _isOrderingRegistered;
private bool _fitPaneAfterInitialRender = true;
private string? _orderStatus;
[Inject]
public required IDashboardClient DashboardClient { get; init; }
[Inject]
public required DashboardDataSource DataSource { get; init; }
[Inject]
public required NavigationManager NavigationManager { get; init; }
[Inject]
public required ILocalStorage LocalStorage { get; init; }
[Inject]
public required ISessionStorage SessionStorage { get; init; }
[Inject]
public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }
[Inject]
public required ResourceMenuBuilder ResourceMenuBuilder { get; init; }
[Inject]
public required IconResolver IconResolver { get; init; }
[Inject]
public required ResourceTagStore TagStore { get; init; }
[Inject]
public required ResourceOrderStore OrderStore { get; init; }
[Inject]
public required Microsoft.FluentUI.AspNetCore.Components.INotificationService ToastService { get; init; }
[Inject]
public required ResourcePaneState PaneState { get; init; }
[Inject]
public required DashboardViewPreferences ViewPreferences { get; init; }
[Inject]
public required IStringLocalizer<Resources.Layout> Loc { get; init; }
[Inject]
public required BrowserTimeProvider TimeProvider { get; init; }
[Inject]
public required IStringLocalizer<Resources.Resources> ResourcesLoc { get; init; }
[Inject]
public required IStringLocalizer<Resources.StructuredLogs> StructuredLogsLoc { get; init; }
[Inject]
public required IStringLocalizer<TerminalStrings> TerminalLoc { get; init; }
[Inject]
public required IStringLocalizer<ControlsStrings> ControlsStringsLoc { get; init; }
[Inject]
public required IStringLocalizer<Columns> ColumnsLoc { get; init; }
[Inject]
public required ILogger<ResourcesLayout> Logger { get; init; }
[Inject]
public required IJSRuntime JS { get; init; }
[CascadingParameter]
public required ViewportInformation ViewportInformation { get; set; }
private ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
internal bool IsTelemetryPane => !DashboardClient.IsEnabled || PaneState.Mode == ResourcePaneMode.Telemetry;
/// <summary>
/// Gets a value indicating whether the initial resource snapshot has been received.
/// </summary>
internal bool IsLoaded => _isLoaded;
/// <summary>
/// Gets the tab of the current page.
/// </summary>
internal ResourceTab CurrentTab { get; private set; }
/// <summary>
/// Gets the names of the resources selected in the resource list, in selection order. The list is empty when no
/// resource is selected, in which case telemetry pages show data from every resource.
/// </summary>
internal IReadOnlyList<string> SelectedResourceNames => _selectedResourceNames;
/// <summary>
/// Gets a value indicating whether more than one resource is selected.
/// </summary>
internal bool IsMultiSelection => _selectedResourceNames.Count > 1;
/// <summary>
/// Gets the resource name when exactly one resource is selected, otherwise <c>null</c>.
/// </summary>
internal string? SelectedResourceName => _selectedResourceNames.Count == 1 ? _selectedResourceNames[0] : null;
/// <summary>
/// The name of the section in the desktop tabs row where tab pages render their filter controls.
/// </summary>
internal const string TabToolbarSectionName = "resource-tab-toolbar";
/// <summary>
/// The name of the section where the current page renders actions beside the resource title.
/// </summary>
internal const string HeaderActionsSectionName = "resource-header-actions";
/// <summary>
/// The name of the section where the current page renders status in the desktop workspace footer.
/// </summary>
internal const string FooterStatusSectionName = "resource-footer-status";
/// <summary>
/// Gets the app model resource that matches <see cref="SelectedResourceName"/>, if any.
/// </summary>
internal ResourceViewModel? SelectedResource { get; private set; }
/// <summary>
/// Gets the app model resources of a multi-resource selection. Selected resources that only send telemetry are
/// not included.
/// </summary>
internal IEnumerable<ResourceViewModel> SelectedResources => _selectedItems.Select(i => i.Resource).OfType<ResourceViewModel>();
internal IReadOnlyList<string> AvailableResourceTags => TagStore.GetAllTags(_resourceByName.Values);
internal ConcurrentDictionary<string, ResourceViewModel> ResourceByName => _resourceByName;
/// <summary>
/// Raised on the renderer's synchronization context after resources change, so pages that display data owned by
/// the layout (such as the resource overview) can refresh.
/// </summary>
internal event Action? ResourcesChanged;
protected override async Task OnInitializedAsync()
{
// Switching between the live view and a recording, including restoring one when the dashboard opens,
// re-creates the page and can dispose this layout while it's still initializing. Capture the token before
// the first await: DisposeAsync cancels and then disposes the source, after which its Token throws.
var cancellationToken = _cts.Token;
_ageRefreshTask = RefreshAgesPeriodicallyAsync(cancellationToken);
_lastLocation = NavigationManager.Uri;
UpdateFromLocation(_lastLocation);
_unviewedErrorCounts = TelemetryRepository.GetResourceUnviewedErrorLogsCount();
UpdateTelemetryOnlyResources();
var paneCollapsedResult = await LocalStorage.GetUnprotectedAsync<bool>(BrowserStorageKeys.ResourcePaneCollapsed);
if (paneCollapsedResult.Success)
{
_isPaneCollapsed = paneCollapsedResult.Value;
}
var paneWidthResult = await LocalStorage.GetUnprotectedAsync<int>(BrowserStorageKeys.ResourcePaneWidth);
if (paneWidthResult.Success)
{
_paneWidth = Math.Clamp(paneWidthResult.Value, MinimumPaneWidthPx, MaximumPaneWidthPx);
}
// The URL selects the mode when navigating from the main navigation. Other resource URLs, such as a row's
// link, don't include it, so the last mode is remembered.
var paneModeResult = await LocalStorage.GetUnprotectedAsync<string>(BrowserStorageKeys.ResourcePaneMode);
if (DashboardClient.IsEnabled && paneModeResult.Success && Enum.TryParse<ResourcePaneMode>(paneModeResult.Value, ignoreCase: true, out var storedPaneMode) && storedPaneMode != ResourcePaneMode.Telemetry)
{
// Migrate the former Tags navigation view to the resource pane's grouping preference.
_grouping = storedPaneMode == ResourcePaneMode.Tags ? ResourcePaneGrouping.Tags : ResourcePaneGrouping.None;
PaneState.SetMode(ResourcePaneMode.Resources);
}
var groupingResult = await LocalStorage.GetUnprotectedAsync<string>(BrowserStorageKeys.ResourcePaneGrouping);
if (groupingResult.Success && Enum.TryParse<ResourcePaneGrouping>(groupingResult.Value, out var grouping) && Enum.IsDefined(grouping))
{
_grouping = grouping;
}
else if (_grouping == ResourcePaneGrouping.Tags)
{
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourcePaneGrouping, _grouping.ToString());
}
await ViewPreferences.EnsureLoadedAsync();
var kindsResult = await LocalStorage.GetUnprotectedAsync<string>(BrowserStorageKeys.ResourcePaneKinds);
if (kindsResult.Success && kindsResult.Value is { } storedKinds)
{
var kinds = storedKinds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_showResources = kinds.Contains(nameof(ResourcePaneKind.Resources), StringComparer.Ordinal);
_showParameters = kinds.Contains(nameof(ResourcePaneKind.Parameters), StringComparer.Ordinal);
_showTelemetryOnlyResources = kinds.Contains(nameof(ResourcePaneKind.TelemetrySources), StringComparer.Ordinal);
}
await ApplyPaneModeFromLocationAsync(_lastLocation);
if (cancellationToken.IsCancellationRequested)
{
return;
}
TagStore.Changed += OnTagsChanged;
ViewPreferences.Changed += OnViewPreferencesChanged;
OrderStore.Changed += OnOrderChanged;
var showHiddenResources = await SessionStorage.GetAsync<bool>(BrowserStorageKeys.ResourcesShowHiddenResources);
if (showHiddenResources.Success)
{
_filter.ShowHiddenResources = showHiddenResources.Value;
}
if (DashboardClient.IsEnabled)
{
// The application name is only correct once the dashboard is connected, and it scopes persisted tree state.
await DashboardClient.WhenConnected;
_collapsedResourceNamesKey = BrowserStorageKeys.CollapsedResourceNamesKey(DashboardClient.ApplicationName);
var collapsedResult = await LocalStorage.GetAsync<List<string>>(_collapsedResourceNamesKey);
if (collapsedResult.Success)
{
foreach (var resourceName in collapsedResult.Value)
{
_collapsedResourceNames.Add(resourceName);
}
}
}
// Don't subscribe after DisposeAsync has run: nothing would dispose these subscriptions.
if (cancellationToken.IsCancellationRequested)
{
return;
}
_logsSubscription = TelemetryRepository.OnNewLogs(null, SubscriptionType.Other, async () =>
{
var counts = TelemetryRepository.GetResourceUnviewedErrorLogsCount();
await InvokeAsync(() =>
{
_unviewedErrorCounts = counts;
StateHasChanged();
});
});
_telemetryResourcesSubscription = TelemetryRepository.OnNewResources(async () =>
{
await InvokeAsync(() =>
{
UpdateTelemetryOnlyResources();
UpdateSelection();
StateHasChanged();
ResourcesChanged?.Invoke();
});
});
// Standalone resources come from OTLP. The disabled AppHost client cannot be awaited or subscribed to.
if (!DashboardClient.IsEnabled)
{
_isLoaded = true;
UpdateTelemetryOnlyResources();
UpdateSelection();
ResourcesChanged?.Invoke();
return;
}
var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken);
foreach (var resource in snapshot)
{
UpsertResource(resource);
}
OrderStore.EnsureResources(snapshot.Where(r => !r.IsParameter)
.OrderBy(r => r.ResourceType).ThenBy(r => r, ResourceViewModelNameComparer.Instance));
UpdateResourceTagFilters();
EnsureGroupOrder();
_isLoaded = true;
UpdateTelemetryOnlyResources();
CarrySelectionAcrossRuns();
UpdateSelection();
UpdatePaneMenuItems();
await TryRedirectLandingAsync();
_resourceSubscriptionTask = Task.Run(async () =>
{
await foreach (var changes in subscription.WithCancellation(cancellationToken).ConfigureAwait(false))
{
foreach (var (changeType, resource) in changes)
{
if (changeType == ResourceViewModelChangeType.Upsert)
{
UpsertResource(resource);
if (!resource.IsParameter)
{
OrderStore.EnsureResources([resource]);
}
}
else if (changeType == ResourceViewModelChangeType.Delete)
{
_resourceByName.TryRemove(resource.Name, out _);
}
}
await InvokeAsync(async () =>
{
UpdateResourceTagFilters();
EnsureGroupOrder();
UpdateTelemetryOnlyResources();
UpdateSelection();
UpdatePaneMenuItems();
await TryRedirectLandingAsync();
StateHasChanged();
ResourcesChanged?.Invoke();
});
}
});
ResourcesChanged?.Invoke();
}
private void UpsertResource(ResourceViewModel resource)
{
_resourceByName[resource.Name] = resource;
// New types and states are visible unless the user has hidden them already.
_filter.ResourceTypesToVisibility.TryAdd(resource.ResourceType, true);
_filter.ResourceStatesToVisibility.TryAdd(resource.State ?? string.Empty, true);
_filter.ResourceHealthStatusesToVisibility.TryAdd(resource.HealthStatus?.Humanize() ?? string.Empty, true);
}
protected override async Task OnParametersSetAsync()
{
// The router sets a new body on every navigation. Selection is updated here, rather than in a
// LocationChanged handler, so it's current before the page in the body renders.
var location = NavigationManager.Uri;
if (string.Equals(location, _lastLocation, StringComparison.Ordinal))
{
return;
}
_lastLocation = location;
_navigatingToTab = null;
var previousTab = CurrentTab;
var previousSelection = _selectedResourceNames;
UpdateFromLocation(location);
await ApplyPaneModeFromLocationAsync(location);
_isDrawerOpen = false;
await TryRedirectLandingAsync();
await PersistSelectedResourcesAsync();
if (previousTab != CurrentTab || !previousSelection.SequenceEqual(_selectedResourceNames, StringComparers.ResourceName))
{
// Selection and query-only terminal focus changes can reuse the page's parameters.
// Notify it so the mounted terminal changes layout even when no resource updates arrive.
ResourcesChanged?.Invoke();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var showResizer = ViewportInformation.IsDesktop && !_isPaneCollapsed;
var registrationChanged = showResizer != _isResizerRegistered || !_isOrderingRegistered;
if (!registrationChanged && _isTabOverflowRegistered && !(_fitPaneAfterInitialRender && _isLoaded && showResizer))
{
return;
}
try
{
_jsModule ??= await JS.InvokeAsync<IJSObjectReference>("import", $"./{Assets["Components/Layout/ResourcesLayout.razor.js"]}");
_selfRef ??= DotNetObjectReference.Create(this);
if (!_isTabOverflowRegistered)
{
_isTabOverflowRegistered = true;
await _jsModule.InvokeVoidAsync("registerTabOverflow", _selfRef);
}
if (!_isOrderingRegistered)
{
await _jsModule.InvokeVoidAsync("registerResourceOrdering", _layoutElement, _selfRef);
_isOrderingRegistered = true;
}
if (registrationChanged && showResizer)
{
await _jsModule.InvokeVoidAsync("registerPaneResizer", _layoutElement, _selfRef, MinimumPaneWidthPx, MaximumPaneWidthPx);
}
else if (registrationChanged)
{
await _jsModule.InvokeVoidAsync("unregisterPaneResizer", _layoutElement);
}
_isResizerRegistered = showResizer;
await _jsModule.InvokeVoidAsync(
"setTabListAriaLabel",
GetTabId(CurrentTab),
Loc[nameof(Dashboard.Resources.Layout.ResourceTabsLandmark)].Value);
if (_fitPaneAfterInitialRender && _isLoaded && showResizer)
{
// Stay armed until rows with actions exist, e.g. when the resources arrive after the first render.
_fitPaneAfterInitialRender = !await _jsModule.InvokeAsync<bool>("fitPaneToActions", _layoutElement);
}
}
catch (JSDisconnectedException)
{
// The circuit disconnected while rendering. There is nothing to register.
}
}
/// <summary>
/// Called by the tab strip when the tabs that no longer fit change. They move to the "..." menu.
/// </summary>
[JSInvokable]
public void SetOverflowTabs(string[] tabIds)
{
if (_isDisposed || _overflowTabIds.SequenceEqual(tabIds))
{
return;
}
_overflowTabIds = tabIds;
StateHasChanged();
}
private IList<MenuButtonItem> GetOverflowMenuItems() =>
GetTabMenuItems().Where(item => _overflowTabIds.Contains(item.Id!.Replace("-menu-item", string.Empty, StringComparison.Ordinal))).ToList();
/// <summary>
/// Called by the pane resizer after the user resizes the resource list.
/// </summary>
[JSInvokable]
public async Task SetPaneWidthAsync(int widthPx)
{
_paneWidth = Math.Clamp(widthPx, MinimumPaneWidthPx, MaximumPaneWidthPx);
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourcePaneWidth, _paneWidth.Value);
StateHasChanged();
}
private string? GetLayoutStyle() => _paneWidth is { } width && ViewportInformation.IsDesktop
? string.Create(CultureInfo.InvariantCulture, $"--resource-pane-expanded-width: {width}px")
: null;
/// <summary>
/// Derives the tab and selected resources from a URL such as <c>/traces/resource/api?type=http</c>.
/// </summary>
private void UpdateFromLocation(string location)
{
var baseRelativePath = NavigationManager.ToBaseRelativePath(location);
var parsedLocation = ParseLocation(baseRelativePath);
var wasAllResources = _hasLocation && _selectedResourceNames.Count == 0;
_hasLocation = true;
CurrentTab = parsedLocation.Tab;
RouteResourceName = parsedLocation.RouteResourceName;
if (!parsedLocation.KeepSelection)
{
_selectedResourceNames = parsedLocation.SelectedResourceNames;
}
// The overview without a selection lists all resources. Entering the resources view that way, for example
// from the navigation rail, restores the last selection instead. Switching to the overview tab while all
// resources are already shown keeps them. Telemetry sources has its own selection, so it must not restore
// the last app-resource selection when its overview route has no selected source.
_pendingLandingRedirect = DashboardClient.IsEnabled
&& ParsePaneMode(baseRelativePath) != ResourcePaneMode.Telemetry
&& parsedLocation.Tab == ResourceTab.Overview
&& parsedLocation.SelectedResourceNames.Count == 0
&& !wasAllResources;
UpdateSelection();
}
/// <summary>
/// Gets the resource in the URL path of the current page. It's usually the selected resource, but with several
/// selected resources, the metrics page uses it for the resource whose instruments are displayed.
/// </summary>
internal string? RouteResourceName { get; private set; }
/// <summary>
/// Gets the URL of the given tab for the resources currently selected in the resource list.
/// </summary>
internal string GetTabUrl(ResourceTab tab)
{
if (!IsMultiSelection && tab is ResourceTab.Overview or ResourceTab.Terminal &&
CurrentTab is ResourceTab.Overview or ResourceTab.Terminal &&
NavigationManager.ToBaseRelativePath(NavigationManager.Uri).StartsWith(
$"{DashboardUrls.ResourceOverviewBasePath}/", StringComparison.OrdinalIgnoreCase))
{
return DashboardUrls.SetResourceTerminalFocus(NavigationManager.Uri, focused: tab == ResourceTab.Terminal);
}
if (tab == ResourceTab.Metrics && _selectedResourceNames.Count == 0 && !DashboardClient.IsEnabled &&
_telemetryOnlyResources.FirstOrDefault(resource => resource.Resource.HasMetrics) is { } metricsResource)
{
return GetTabUrl(tab, [metricsResource.Name], metricsResource.Name);
}
return GetTabUrl(tab, _selectedResourceNames, RouteResourceName);
}
internal string AddPaneToUrl(string url) => DashboardClient.IsEnabled && IsTelemetryPane ? DashboardUrls.AddTelemetryPane(url) : url;
private async Task ApplyPaneModeFromLocationAsync(string location)
{
if (!DashboardClient.IsEnabled)
{
PaneState.SetMode(ResourcePaneMode.Telemetry);
return;
}
var mode = ParsePaneMode(NavigationManager.ToBaseRelativePath(location));
if (mode is null)
{
// A direct telemetry resource link can select the telemetry pane without a query parameter.
mode = _selectedItems.Any(i => i.TelemetryOnly is not null) ? ResourcePaneMode.Telemetry
: PaneState.Mode == ResourcePaneMode.Telemetry ? ResourcePaneMode.Resources : PaneState.Mode;
}
if (mode == ResourcePaneMode.Tags)
{
await SetGroupingAsync(ResourcePaneGrouping.Tags);
mode = ResourcePaneMode.Resources;
}
PaneState.SetMode(mode.Value);
if (mode == ResourcePaneMode.Telemetry)
{
return;
}
try
{
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourcePaneMode, mode.Value.ToString());
}
catch (JSDisconnectedException)
{
// The circuit disconnected while navigating. There is nothing to persist to.
}
}
/// <summary>
/// Gets the resource list mode from a URL such as <c>/resources?pane=tags</c>, or <c>null</c> when the URL
/// doesn't specify one.
/// </summary>
internal static ResourcePaneMode? ParsePaneMode(string baseRelativePath)
{
var queryIndex = baseRelativePath.IndexOf('?');
if (queryIndex < 0)
{
return null;
}
var query = baseRelativePath[queryIndex..];
var fragmentIndex = query.IndexOf('#');
if (fragmentIndex >= 0)
{
query = query[..fragmentIndex];
}
if (!QueryHelpers.ParseQuery(query).TryGetValue(DashboardUrls.ResourcePaneQueryName, out var values))
{
return null;
}
return values.ToString().ToLowerInvariant() switch
{
DashboardUrls.ResourcePaneResourcesValue => ResourcePaneMode.Resources,
DashboardUrls.ResourcePaneTagsValue => ResourcePaneMode.Tags,
DashboardUrls.ResourcePaneTelemetryValue => ResourcePaneMode.Telemetry,
_ => null
};
}
internal static ResourceLocation ParseLocation(string baseRelativePath)
{
var path = baseRelativePath;
var query = string.Empty;
var fragmentIndex = path.IndexOf('#');
if (fragmentIndex >= 0)
{
path = path[..fragmentIndex];
}
var queryIndex = path.IndexOf('?');
if (queryIndex >= 0)
{
query = path[queryIndex..];
path = path[..queryIndex];
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return new ResourceLocation(ResourceTab.Overview, RouteResourceName: null, SelectedResourceNames: [], KeepSelection: false);
}
// Trace details are addressed by trace id. Keep the previously selected resources so the resource header
// and tabs stay stable while drilling into a trace.
if (string.Equals(segments[0], DashboardUrls.TracesBasePath, StringComparisons.UrlPath) &&
segments.Length > 1 &&
string.Equals(segments[1], "detail", StringComparisons.UrlPath))
{
return new ResourceLocation(ResourceTab.Traces, RouteResourceName: null, SelectedResourceNames: [], KeepSelection: true);
}
var tab = segments[0].ToLowerInvariant() switch
{
DashboardUrls.ParametersBasePath => ResourceTab.Parameters,
DashboardUrls.ConsoleLogBasePath => ResourceTab.Console,
DashboardUrls.TerminalsBasePath => ResourceTab.Terminal,
DashboardUrls.StructuredLogsBasePath => ResourceTab.StructuredLogs,
DashboardUrls.TracesBasePath => ResourceTab.Traces,
DashboardUrls.MetricsBasePath => ResourceTab.Metrics,
_ => ResourceTab.Overview
};
string? routeResourceName = null;
if (tab == ResourceTab.Overview)
{
// resources/{name}
if (segments.Length > 1)
{
routeResourceName = Uri.UnescapeDataString(segments[1]);
}
}
else if (segments.Length > 2 && string.Equals(segments[1], "resource", StringComparisons.UrlPath))
{
// {page}/resource/{name}
routeResourceName = Uri.UnescapeDataString(segments[2]);
}
// Several selected resources are listed in the query string, for example ?resource=api&resource=worker.
var selectedResourceNames = new List<string>();
var queryParameters = QueryHelpers.ParseQuery(query);
if (queryParameters.TryGetValue(DashboardUrls.ResourceSelectionQueryName, out var queryValues))
{
foreach (var value in queryValues)
{
if (!string.IsNullOrEmpty(value) && !selectedResourceNames.Contains(value, StringComparers.ResourceName))
{
selectedResourceNames.Add(value);
}
}
}
if (selectedResourceNames.Count == 0 && routeResourceName is not null)
{
selectedResourceNames.Add(routeResourceName);
}
if (tab == ResourceTab.Overview && routeResourceName is not null &&
selectedResourceNames.Count == 1 &&
queryParameters.TryGetValue(DashboardUrls.ResourceTerminalQueryName, out var terminalFocus) &&
string.Equals(terminalFocus, "true", StringComparison.OrdinalIgnoreCase))
{
tab = ResourceTab.Terminal;
}
return new ResourceLocation(tab, routeResourceName, selectedResourceNames, KeepSelection: false);
}
private void UpdateSelection()
{
_selectedItems = _selectedResourceNames.Select(name =>
{
if (ResourceViewModel.TryGetResourceByName(name, _resourceByName, out var resource))
{
return new SelectedResourceItem(name, resource, TelemetryOnly: null);
}
var telemetryOnly = _telemetryOnlyResources.FirstOrDefault(r => string.Equals(r.Name, name, StringComparisons.ResourceName));
return new SelectedResourceItem(name, Resource: null, telemetryOnly);
}).ToList();
SelectedResource = _selectedItems is [{ Resource: { } selected }] ? selected : null;
if (_isLoaded)
{
PaneState.SelectionRunId = DataSource.SelectedRun.RunId;
PaneState.SelectionPersistentKeys = _selectedItems
.Where(i => i.Resource is not null)
.ToDictionary(i => i.Name, i => i.Resource!.PersistentKey, StringComparers.ResourceName);
}
if (DashboardClient.IsEnabled && _selectedItems.Any(i => i.TelemetryOnly is not null) &&
ParsePaneMode(NavigationManager.ToBaseRelativePath(NavigationManager.Uri)) is null)
{
PaneState.SetMode(ResourcePaneMode.Telemetry);
}
}
/// <summary>
/// After switching between the live view and a recording, replaces selected resources that don't exist in the
/// new run with the same resource in it, matched by persistent key: replica names end with a random suffix that
/// differs between runs. Resources with no match are dropped, which shows every resource when none is left.
/// </summary>
private void CarrySelectionAcrossRuns()
{
var previousRunId = PaneState.SelectionRunId;
if (previousRunId is null || string.Equals(previousRunId, DataSource.SelectedRun.RunId, StringComparison.Ordinal) || _selectedResourceNames.Count == 0)
{
return;
}
var selection = new List<string>();
foreach (var name in _selectedResourceNames)
{
string? match = null;
if (IsKnownResourceName(name) || _telemetryOnlyResources.Any(r => string.Equals(r.Name, name, StringComparisons.ResourceName)))
{
match = name;
}
else if (PaneState.SelectionPersistentKeys.TryGetValue(name, out var persistentKey))
{
match = _resourceByName.Values
.Where(r => string.Equals(r.PersistentKey, persistentKey, StringComparisons.ResourceName))
.Select(r => r.Name)
.FirstOrDefault();
}
if (match is not null && !selection.Contains(match, StringComparers.ResourceName))
{
selection.Add(match);
}
}
if (!selection.SequenceEqual(_selectedResourceNames, StringComparers.ResourceName))
{
NavigationManager.NavigateTo(GetSelectionUrl(selection), new NavigationOptions { ReplaceHistoryEntry = true });
}
}
private async Task TryRedirectLandingAsync()
{
if (!_pendingLandingRedirect || !_isLoaded)
{
return;
}
_pendingLandingRedirect = false;
List<string> resourceNames;
try
{
// Entering the resources view restores the selection made earlier in this browser session. Without one,
// such as when the dashboard opens, all resources stay selected and the overview summarizes the app.
var lastSelected = await SessionStorage.GetAsync<List<string>>(BrowserStorageKeys.LastSelectedResources);
resourceNames = lastSelected is { Success: true, Value: { } names }
? names.Where(IsKnownResourceName).ToList()
: [];
}
catch (JSDisconnectedException)
{
return;
}
if (resourceNames.Count > 0)
{
NavigationManager.NavigateTo(GetTabUrl(ResourceTab.Overview, resourceNames), new NavigationOptions { ReplaceHistoryEntry = true });
}
}
private bool IsKnownResourceName(string name) =>
ResourceViewModel.TryGetResourceByName(name, _resourceByName, out _);
private async Task PersistSelectedResourcesAsync()
{
// Navigating to a page without a selection, such as all structured logs, doesn't clear the remembered
// selection. Only clearing the selection in the resource list does.
if (_selectedResourceNames.Count == 0)
{
return;
}
await SetStoredSelectionAsync(_selectedResourceNames);
}
private async Task SetStoredSelectionAsync(IReadOnlyList<string> resourceNames)
{
if (IsTelemetryPane)
{
return;
}
try
{
await SessionStorage.SetAsync(BrowserStorageKeys.LastSelectedResources, resourceNames.ToList());
}
catch (JSDisconnectedException)
{
// The circuit disconnected while navigating. There is nothing to persist to.
}
}
private void UpdateTelemetryOnlyResources()
{
// Resources that send telemetry but aren't part of the app model (for example a browser app) are listed
// in their own pane so they don't appear in Resources or Tags.
var telemetryResources = TelemetryRepository.GetResources();
var appModelKeys = new HashSet<ResourceKey>();
foreach (var resource in _resourceByName.Values)
{
if (TelemetryRepository.GetResourceByCompositeName(resource.Name) is { } otlpResource)
{
appModelKeys.Add(otlpResource.ResourceKey);
}
}
_telemetryOnlyResources = telemetryResources
.Where(r => !appModelKeys.Contains(r.ResourceKey))
.Select(r => new TelemetryOnlyResource(OtlpHelpers.GetResourceName(r, telemetryResources), r))
.OrderBy(r => r.Name, StringComparers.ResourceName)
.ToList();
}
private IEnumerable<TelemetryOnlyResource> GetVisibleTelemetryResources() =>
_telemetryOnlyResources.Where(r =>
(!DashboardClient.IsEnabled || _showTelemetryOnlyResources || IsSelected(r.Name)) &&
r.Name.Contains(_filter.TextFilter, StringComparisons.UserTextSearch));
internal IEnumerable<ResourceGridViewModel> GetPaneRows()
{
if (!_showResources)
{
return [];
}
var positions = OrderStore.GetPositions();
var filteredResources = _resourceByName.Values
.Where(r => !r.IsParameter && _filter.FilterAllResourceKinds(r, _filter.TextFilter))
.Select(r => new ResourceGridViewModel { Resource = r })
.OrderBy(r => positions.GetValueOrDefault(r.Resource.PersistentKey, long.MaxValue))
.ThenBy(r => r.Resource, ResourceViewModelNameComparer.Instance)
.ToList();
// Nested resources are placed under their parent after sorting so children keep their order.
return ResourceGridViewModel.OrderNestedResources(filteredResources, r => _collapsedResourceNames.Contains(r.PersistentKey))
.Where(r => !r.IsHidden);
}
private List<ResourceViewModel> GetParameterPaneRows() =>
!_showParameters ? [] : _resourceByName.Values
.Where(r => r.IsParameter && _filter.FilterAllResourceKinds(r, _filter.TextFilter))
.OrderBy(r => r.HasMissingParameterValueState() ? 0 : 1)
.ThenBy(r => r, ResourceViewModelNameComparer.Instance)
.ToList();
/// <summary>
/// Gets the filtered resources grouped by the selected property. Text search matches group names or resources.
/// </summary>
private List<ResourceGroup> GetResourceGroups()
{
// Tag groups include parameters (see GetTagGroups), so they can have content when only parameters are shown.
if (_grouping == ResourcePaneGrouping.Tags ? !_showResources && !_showParameters : !_showResources)
{
return [];
}
var groups = _grouping == ResourcePaneGrouping.Tags ? GetTagGroups() : _resourceByName.Values
.Where(r => !r.IsParameter && _filter.FilterAllResourceKinds(r, textFilter: string.Empty))
.GroupBy(r => _grouping == ResourcePaneGrouping.ResourceType ? r.ResourceType
: string.IsNullOrEmpty(r.State) ? nameof(KnownResourceState.Unknown) : r.State, StringComparer.OrdinalIgnoreCase)
.Select(group => new ResourceGroup(group.Key,
group.Where(r => GetGroupName(group.Key).Contains(_filter.TextFilter, StringComparisons.UserTextSearch) || r.MatchesFilter(_filter.TextFilter))
.OrderBy(r => r, ResourceViewModelNameComparer.Instance).ToList()))
.Where(group => group.Resources.Count > 0)
.OrderBy(group => GetGroupName(group.Value), StringComparer.CurrentCultureIgnoreCase)
.ToList();
var positions = OrderStore.GetGroupPositions(_grouping.ToString());
return groups.OrderBy(group => positions.GetValueOrDefault(GetGroupOrderKey(group), long.MaxValue)).ToList();
}
private string GetGroupName(string? value) => GetGroupName(_grouping, value);
private string GetGroupName(ResourcePaneGrouping grouping, string? value) => grouping switch
{
ResourcePaneGrouping.Tags => value ?? Loc[nameof(Resources.Layout.ResourcePaneUntaggedGroup)],
ResourcePaneGrouping.State => string.IsNullOrEmpty(value) ? ColumnsLoc[nameof(Columns.UnknownStateLabel)] : value.Humanize(),
_ => value ?? string.Empty
};
private void EnsureGroupOrder()
{
// Register groups independently of search/type/state/health filters, so a filtered view cannot change
// their saved order. Hidden resources participate once the user chooses to show them.
var resources = _resourceByName.Values.Where(r => !r.IsResourceHidden(_filter.ShowHiddenResources)).ToList();
var tagsByResource = TagStore.GetTagsByResource(resources);
var tags = TagStore.GetAllTags(resources)
.Where(tag => resources.Any(r => tagsByResource.TryGetValue(r.DisplayName, out var resourceTags) && resourceTags.Contains(tag, StringComparer.OrdinalIgnoreCase)))
.ToList();
if (resources.Any(r => !tagsByResource.ContainsKey(r.DisplayName)))
{
tags.Add(string.Empty);
}
OrderStore.EnsureGroups(nameof(ResourcePaneGrouping.Tags), tags);
OrderStore.EnsureGroups(nameof(ResourcePaneGrouping.ResourceType), resources.Select(r => r.ResourceType)
.Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.CurrentCultureIgnoreCase));
OrderStore.EnsureGroups(nameof(ResourcePaneGrouping.State), resources
.Select(r => string.IsNullOrEmpty(r.State) ? nameof(KnownResourceState.Unknown) : r.State)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => GetGroupName(ResourcePaneGrouping.State, value), StringComparer.CurrentCultureIgnoreCase));
}
// Tags are normalized to nonempty values, so the empty key uniquely identifies the synthetic untagged group.
private static string GetGroupOrderKey(ResourceGroup group) => group.Value ?? string.Empty;
private List<MenuButtonItem> GetGroupingMenuItems() =>
new[]
{
new MenuButtonItem
{
IsHeader = true,
Text = Loc[nameof(Resources.Layout.ResourcePaneGrouping)],
Icon = new Icons.Regular.Size20.GroupList(),
RenderKey = "resource-grouping-header"
}
}
.Concat(Enum.GetValues<ResourcePaneGrouping>().Select(grouping => new MenuButtonItem
{
Id = $"resource-grouping-{grouping}",
Class = "resource-grouping-menu-item",
Text = Loc[grouping switch
{
ResourcePaneGrouping.None => nameof(Resources.Layout.ResourcePaneGroupingNone),
ResourcePaneGrouping.Tags => nameof(Resources.Layout.NavMenuTagsTab),
ResourcePaneGrouping.ResourceType => nameof(Resources.Layout.ResourcePaneGroupingResourceType),
_ => nameof(Resources.Layout.ResourcePaneGroupingState)
}],
Tooltip = string.Empty,
Role = MenuItemRole.Radio,
Checked = _grouping == grouping,
OnClick = () => SetGroupingAsync(grouping)
}))
.ToList();
private async Task SetGroupingAsync(ResourcePaneGrouping grouping)
{
_grouping = grouping;
StateHasChanged();
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourcePaneGrouping, grouping.ToString());
}
// Resources with multiple tags appear in each group; untagged resources initially appear last. Parameters are part of
// the groups too, after the group's resources: an app tags a parameter like the resource that uses it, so the
// parameter shows next to that resource.
private List<ResourceGroup> GetTagGroups()
{
var textFilter = _filter.TextFilter;
var resources = _resourceByName.Values
.Where(r => (r.IsParameter ? _showParameters : _showResources) && _filter.FilterAllResourceKinds(r, textFilter: string.Empty))
.OrderBy(r => r.IsParameter)
.ThenBy(r => r.ResourceType)
.ThenBy(r => r, ResourceViewModelNameComparer.Instance)
.ToList();
var tagsByResource = TagStore.GetTagsByResource(resources);
var groups = new List<ResourceGroup>();
foreach (var tag in TagStore.GetAllTags(resources))
{
// A resource can carry several tags. When the tag filter hides a tag, its group stays hidden even though
// resources shown through another tag also carry it.
if (!IsTagVisible(tag))
{
continue;
}
var tagMatchesFilter = textFilter.Length == 0 || tag.Contains(textFilter, StringComparisons.UserTextSearch);
var taggedResources = resources
.Where(r => tagsByResource.TryGetValue(r.DisplayName, out var tags) && tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
.Where(r => tagMatchesFilter || r.MatchesFilter(textFilter))
.ToList();
if (taggedResources.Count > 0)
{
groups.Add(new ResourceGroup(tag, taggedResources));
}
}
var untaggedResources = resources
.Where(r => !tagsByResource.ContainsKey(r.DisplayName))
.Where(r => textFilter.Length == 0 || r.MatchesFilter(textFilter))
.ToList();
if (untaggedResources.Count > 0 && IsTagVisible(string.Empty))
{
groups.Add(new ResourceGroup(Value: null, untaggedResources));
}
return groups;
}
// The tag filter keys untagged resources by the empty string, and tags missing from it are visible.
private bool IsTagVisible(string tag) =>
!_filter.ResourceTagsToVisibility.TryGetValue(tag, out var visible) || visible;
private bool IsGroupCollapsed(ResourceGroup group) => _collapsedGroups.Contains(GetGroupKey(group));
private bool IsSectionCollapsed(string sectionKey) => _collapsedGroups.Contains(sectionKey);
private void ToggleGroup(ResourceGroup group)
{
ToggleCollapsedGroup(GetGroupKey(group));
}
private void ToggleSection(string sectionKey)
{
ToggleCollapsedGroup(sectionKey);
}
private void ToggleCollapsedGroup(string key)
{
if (!_collapsedGroups.Remove(key))
{
_collapsedGroups.Add(key);
}
}
private string GetGroupKey(ResourceGroup group) => $"{_grouping}:{group.Value}";
private void OnTagsChanged() => _ = InvokeAsync(() =>
{
if (_isLoaded)
{
UpdateResourceTagFilters();
EnsureGroupOrder();
}
StateHasChanged();
});
private IReadOnlyList<ResourcePaneFilter.FilterGroup> GetFilterGroups() =>
[
new("types", ResourcesLoc[nameof(Dashboard.Resources.Resources.ResourcesResourceTypesHeader)], _filter.ResourceTypesToVisibility, r => [r.ResourceType]),
new("states", ResourcesLoc[nameof(Dashboard.Resources.Resources.ResourcesResourceStatesHeader)], _filter.ResourceStatesToVisibility, r => [r.State ?? string.Empty]),
new("health", ResourcesLoc[nameof(Dashboard.Resources.Resources.ResourcesDetailsHealthStateProperty)], _filter.ResourceHealthStatusesToVisibility, r => [r.HealthStatus?.Humanize() ?? string.Empty]),
new("tags",
Loc[nameof(Resources.Layout.NavMenuTagsTab)],
_filter.ResourceTagsToVisibility,
r => _filter.ResourceTagsByResource.GetValueOrDefault(r.DisplayName) ?? [string.Empty],
key => string.IsNullOrEmpty(key) ? Loc[nameof(Resources.Layout.ResourcePaneUntaggedGroup)] : key)
];
private IEnumerable<ResourceViewModel> GetFilterCountResources() =>
_resourceByName.Values.Where(r => !r.IsResourceHidden(_filter.ShowHiddenResources));
private void UpdateResourceTagFilters()
{
var resources = _resourceByName.Values.ToList();
var tagsByResource = TagStore.GetTagsByResource(resources);
var filterKeys = TagStore.GetAllTags(resources).ToHashSet(StringComparer.OrdinalIgnoreCase);
if (resources.Any(r => !tagsByResource.ContainsKey(r.DisplayName)))
{
filterKeys.Add(string.Empty);
}
foreach (var key in _filter.ResourceTagsToVisibility.Keys)
{
if (!filterKeys.Contains(key))
{
_filter.ResourceTagsToVisibility.TryRemove(key, out _);
}
}
foreach (var key in filterKeys)
{
_filter.ResourceTagsToVisibility.TryAdd(key, true);
}
_filter.ResourceTagsByResource = tagsByResource;
}
private bool NoFiltersSet =>
_filter.ResourceTypesToVisibility.Values.All(v => v) &&
_filter.ResourceStatesToVisibility.Values.All(v => v) &&
_filter.ResourceHealthStatusesToVisibility.Values.All(v => v) &&
_filter.ResourceTagsToVisibility.Values.All(v => v);
private bool CanReorderPane =>
_isLoaded && !IsTelemetryPane &&
_filter.TextFilter.Length == 0 && NoFiltersSet;
private bool CanReorderResources => CanReorderPane && _grouping == ResourcePaneGrouping.None;
private bool CanReorderGroups => CanReorderPane && _grouping != ResourcePaneGrouping.None;
private string GetReorderParentName(ResourceViewModel resource)
{
var parentName = resource.GetResourcePropertyValue(KnownProperties.Resource.ParentName);
return parentName is not null && _resourceByName.TryGetValue(parentName, out var parent) && _filter.Filter(parent)
? parentName : string.Empty;
}
[JSInvokable]
public async Task<bool> ReorderResourceAsync(string resourceName, string targetName, bool after)
{
// Revalidate on the server: filters, grouping, or the resource tree can change during a drag.
if (!CanReorderResources ||
!_resourceByName.TryGetValue(resourceName, out var resource) ||
!_resourceByName.TryGetValue(targetName, out var target) ||
!_filter.Filter(resource) || !_filter.Filter(target) ||
!StringComparers.ResourceName.Equals(GetReorderParentName(resource), GetReorderParentName(target)))
{
Logger.LogDebug("Rejected resource reorder from '{ResourceName}' to '{TargetName}' because the visible list changed or the resources aren't siblings.", resourceName, targetName);
await ToastService.ShowWarningToastAsync(Loc[nameof(Resources.Layout.ResourcePaneReorderUnavailable)]);
return false;
}
if (!OrderStore.TryMove(resource.PersistentKey, target.PersistentKey, after))
{
await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.Layout.ResourcePaneReorderFailed)]);
return false;
}
_orderStatus = string.Format(CultureInfo.CurrentCulture,
Loc[after ? nameof(Resources.Layout.ResourcePaneMovedAfter) : nameof(Resources.Layout.ResourcePaneMovedBefore)],
GetResourceName(resource), GetResourceName(target));
StateHasChanged();
return true;
}
private void OnOrderChanged() => _ = InvokeAsync(StateHasChanged);
[JSInvokable]
public async Task<bool> ReorderGroupAsync(string grouping, string groupKey, string targetKey, bool after)
{
var groups = CanReorderGroups && string.Equals(grouping, _grouping.ToString(), StringComparison.Ordinal)
? GetResourceGroups() : [];
var group = groups.Find(group => StringComparers.ResourceName.Equals(GetGroupKey(group), groupKey));
var target = groups.Find(group => StringComparers.ResourceName.Equals(GetGroupKey(group), targetKey));
if (group is null || target is null)
{
Logger.LogDebug("Rejected group reorder from '{GroupKey}' to '{TargetKey}' because the grouping or visible list changed.", groupKey, targetKey);
await ToastService.ShowWarningToastAsync(Loc[nameof(Resources.Layout.ResourcePaneGroupReorderUnavailable)]);
return false;
}
if (!OrderStore.TryMoveGroup(grouping, GetGroupOrderKey(group), GetGroupOrderKey(target), after))
{
await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.Layout.ResourcePaneGroupReorderFailed)]);
return false;
}
_orderStatus = string.Format(CultureInfo.CurrentCulture,
Loc[after ? nameof(Resources.Layout.ResourcePaneMovedAfter) : nameof(Resources.Layout.ResourcePaneMovedBefore)],
GetGroupName(group.Value), GetGroupName(target.Value));
StateHasChanged();
return true;
}
private async Task OnToggleCollapseAsync(ResourceGridViewModel viewModel)
{
if (!_collapsedResourceNames.Remove(viewModel.Resource.PersistentKey))
{
_collapsedResourceNames.Add(viewModel.Resource.PersistentKey);
}
await PersistCollapsedResourceNamesAsync();
UpdatePaneMenuItems();
}
private async Task PersistCollapsedResourceNamesAsync()
{
if (_collapsedResourceNamesKey is not null)
{
await LocalStorage.SetAsync(_collapsedResourceNamesKey, _collapsedResourceNames.ToList());
}
}
private async Task TogglePaneCollapsedAsync()
{
_isPaneCollapsed = !_isPaneCollapsed;
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourcePaneCollapsed, _isPaneCollapsed);
}
private void OpenDrawer() => _isDrawerOpen = true;
private void CloseDrawer() => _isDrawerOpen = false;
private void UpdatePaneMenuItems()
{
_paneMenuItems.Clear();
var resourcesWithChildren = _resourceByName.Values
.Where(r => !r.IsResourceHidden(_filter.ShowHiddenResources))
.Where(r => _resourceByName.Values.Any(nested => string.Equals(nested.GetResourcePropertyValue(KnownProperties.Resource.ParentName), r.Name, StringComparisons.ResourceName)))
.ToList();
if (resourcesWithChildren.Count > 0)
{
var hasCollapsed = resourcesWithChildren.Any(r => _collapsedResourceNames.Contains(r.PersistentKey));
_paneMenuItems.Add(new MenuButtonItem
{
Text = hasCollapsed ? ResourcesLoc[nameof(Resources.Resources.ResourceExpandAllChildren)] : ResourcesLoc[nameof(Resources.Resources.ResourceCollapseAllChildren)],
Icon = hasCollapsed ? new Icons.Regular.Size16.Eye() : new Icons.Regular.Size16.EyeOff(),
OnClick = async () =>
{
foreach (var resource in resourcesWithChildren)
{
if (hasCollapsed)
{
_collapsedResourceNames.Remove(resource.PersistentKey);
}
else
{
_collapsedResourceNames.Add(resource.PersistentKey);
}
}
await PersistCollapsedResourceNamesAsync();
UpdatePaneMenuItems();
StateHasChanged();
}
});
}
CommonMenuItems.AddToggleHiddenResourcesMenuItem(
_paneMenuItems,
ControlsStringsLoc,
_filter.ShowHiddenResources,
_resourceByName.Values,
SessionStorage,
EventCallback.Factory.Create<bool>(this, value =>
{
_filter.ShowHiddenResources = value;
EnsureGroupOrder();
UpdatePaneMenuItems();
}));
}
// The density is set in the settings panel, which isn't part of this layout.
private void OnViewPreferencesChanged() => InvokeAsync(StateHasChanged);
private bool IsCompact => ViewPreferences.Density == ResourceListDensity.Compact;
private bool IsSpacious => ViewPreferences.Density == ResourceListDensity.Spacious;
// How long ago a resource started, for the detail line of a spacious row, and the same with the exact time for its tooltip.
private string? GetStartedAge(ResourceViewModel resource) =>
resource.StartTimeStamp is { } startTimeStamp
? Aspire.Dashboard.Components.Controls.DashboardRunSelect.FormatRunAge(new DateTimeOffset(startTimeStamp), TimeProvider.GetUtcNow(), Loc)
: null;
private string? GetStartedTooltip(ResourceViewModel resource) =>
resource.StartTimeStamp is { } startTimeStamp
? $"{GetStartedAge(resource)} ({FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, startTimeStamp)})"
: null;
// Spacious rows show the age, so re-render now and then to keep it current.
private async Task RefreshAgesPeriodicallyAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30), TimeProvider);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
if (IsSpacious)
{
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
}
private static IEnumerable<CommandViewModel> GetHighlightedCommands(ResourceViewModel resource) => resource.Commands
.Where(c => c.IsHighlighted && c.State != CommandViewModelState.Hidden)
.Take(DashboardUIHelpers.MaxHighlightedCommands);
private bool IsCommandDisabled(ResourceViewModel resource, CommandViewModel command) =>
DashboardClient.IsReadOnly || command.State == CommandViewModelState.Disabled || DashboardCommandExecutor.IsExecuting(resource.Name, command.Name);
private List<MenuButtonItem> GetResourceMenuItems(ResourceViewModel resource)
{
var items = new List<MenuButtonItem>();
if (CanViewResourceTerminal(resource))
{
items.Add(new MenuButtonItem
{
Text = TerminalLoc[nameof(TerminalStrings.TerminalTitle)],
Icon = new Icons.Regular.Size20.WindowConsole(),
OnClick = () =>
{
NavigateToResourceTerminal(resource);
return Task.CompletedTask;
}
});
}
ResourceMenuBuilder.AddMenuItems(
items,
resource,
_resourceByName,
onViewDetails: EventCallback.Empty,
EventCallback.Factory.Create<CommandViewModel>(this, command => ExecuteResourceCommandAsync(resource, command)),
(r, command) => DashboardCommandExecutor.IsExecuting(r.Name, command.Name),
showViewDetails: false,
showTerminalItem: false,
showConsoleLogsItem: false,
showUrls: false);
return items;
}
private bool CanViewResourceTerminal(ResourceViewModel resource) =>
DashboardClient.IsEnabled && !DashboardClient.IsReadOnly && ResourceSelectHelpers.HasUsableTerminal(resource);
private string GetResourceTerminalUrl(ResourceViewModel resource)
{
var location = new Uri(NavigationManager.Uri);
var query = HttpUtility.ParseQueryString(location.Query);
// A row shortcut selects this resource alone, even from an aggregate page such as
// /resources?resource=api&resource=worker&pane=tags#output. Keep the remaining view state.
query.Remove(DashboardUrls.ResourceSelectionQueryName);
var url = DashboardUrls.ResourceOverviewUrl(GetResourceName(resource)) +
(query.Count > 0 ? $"?{query}" : string.Empty) + location.Fragment;
return DashboardUrls.SetResourceTerminalFocus(url, focused: true);
}
private void NavigateToResourceTerminal(ResourceViewModel resource)
{
CloseDrawer();
NavigationManager.NavigateTo(GetResourceTerminalUrl(resource));
}
private Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command)
{
if (resource.IsParameter &&
string.Equals(command.Name, CommandViewModel.SetParameterCommand, StringComparisons.CommandName))
{
NavigationManager.NavigateTo(DashboardUrls.ResourceOverviewUrl(GetResourceName(resource)));
return Task.CompletedTask;
}
return DashboardCommandExecutor.ExecuteAsync(resource, command, GetResourceName);
}
internal string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName);
private string GetResourceTypeLabel(ResourceViewModel resource) => resource.GetResourceTypeDisplayName(ResourcesLoc);
private string GetResourceAccessibilityDescription(ResourceViewModel resource, string stateText, int errorCount)
{
List<string> descriptions = [stateText, GetResourceTypeLabel(resource)];
if (resource.IsResourceHidden(showHiddenResources: false))
{
descriptions.Add(ColumnsLoc[nameof(Columns.HiddenResourceIconTooltip)]);
}
if (errorCount > 0)
{
descriptions.Add(string.Format(CultureInfo.CurrentCulture, Loc[nameof(Resources.Layout.ResourcePaneUnviewedErrors)], errorCount));
}
return string.Join("; ", descriptions);
}
private bool HasMultipleReplicas(ResourceViewModel resource)
=> _resourceByName.Values.Count(r => string.Equals(r.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) > 1;
private OtlpResource? GetTelemetryResource(ResourceViewModel resource) => TelemetryRepository.GetResourceByCompositeName(resource.Name);
internal int GetUnviewedErrorCount(ResourceViewModel resource)
{
return _unviewedErrorCounts is not null &&
GetTelemetryResource(resource) is { } otlpResource &&
_unviewedErrorCounts.TryGetValue(otlpResource.ResourceKey, out var count)
? count
: 0;
}
private TelemetryOnlyResource? SelectedTelemetryOnlyResource =>
_selectedItems is [{ TelemetryOnly: { } telemetryOnly }] ? telemetryOnly : null;
/// <summary>
/// Gets the telemetry keys for a multi-resource selection, or all sources in the current pane when there is
/// no selection. A single resource is filtered by the page's resource key.
/// </summary>
internal IReadOnlyList<ResourceKey>? GetSelectionTelemetryKeys(IReadOnlyCollection<string>? resourceNames)
{
if (resourceNames is null || resourceNames.Count == 0)
{
resourceNames = _selectedResourceNames;
}
if (resourceNames.Count == 0)
{
return GetPaneTelemetryKeys();
}
if (resourceNames.Count < 2)
{
var name = resourceNames.First();
var hasTelemetry = IsTelemetryPane
? _telemetryOnlyResources.Any(r => string.Equals(r.Name, name, StringComparisons.ResourceName))
: ResourceViewModel.TryGetResourceByName(name, _resourceByName, out var resource) && GetTelemetryResource(resource) is not null;
return hasTelemetry ? null : [];
}
var keys = new List<ResourceKey>();
foreach (var name in resourceNames)
{
OtlpResource? otlpResource = null;
if (ResourceViewModel.TryGetResourceByName(name, _resourceByName, out var resource))
{
otlpResource = GetTelemetryResource(resource);
}
else if (_telemetryOnlyResources.FirstOrDefault(r => string.Equals(r.Name, name, StringComparisons.ResourceName)) is { } telemetryOnly)
{
otlpResource = telemetryOnly.Resource;
}
if (otlpResource is not null && !keys.Contains(otlpResource.ResourceKey))
{
keys.Add(otlpResource.ResourceKey);
}
}
return keys;
}
internal IReadOnlyList<ResourceKey> GetPaneTelemetryKeys()
{
if (IsTelemetryPane)
{
return _telemetryOnlyResources.Select(r => r.Resource.ResourceKey).Distinct().ToList();
}
// "All resources" covers the kinds the pane lists. Parameters don't send telemetry.
IEnumerable<ResourceKey> keys = _showResources
? _resourceByName.Values.Where(r => !r.IsParameter).Select(GetTelemetryResource).OfType<OtlpResource>().Select(r => r.ResourceKey)
: [];
if (_showTelemetryOnlyResources)
{
keys = keys.Concat(_telemetryOnlyResources.Select(r => r.Resource.ResourceKey));
}
return keys.Distinct().ToList();
}
internal bool IsKindShown(ResourcePaneKind kind) => kind switch
{
ResourcePaneKind.Resources => _showResources,
ResourcePaneKind.Parameters => _showParameters,
_ => _showTelemetryOnlyResources
};
/// <summary>
/// Gets the names of the app model resources that "all resources" covers, or null when it covers every one of
/// them, which is the case with both resources and parameters shown.
/// </summary>
internal IReadOnlyList<string>? GetScopeResourceNames()
{
if (_showResources && _showParameters)
{
return null;
}
return [.. _resourceByName.Values
.Where(r => r.IsParameter ? _showParameters : _showResources)
.Where(r => !r.IsResourceHidden(_filter.ShowHiddenResources))
.Select(GetResourceName)];
}
/// <summary>
/// Gets the title for the page when nothing is selected, naming the kinds of items it covers.
/// </summary>
private string GetScopeTitle()
{
if (!DashboardClient.IsEnabled)
{
return Loc[nameof(Resources.Layout.TelemetryHeaderAllSources)];
}
if (_showResources && _showParameters)
{
return Loc[nameof(Resources.Layout.ResourceHeaderAllResources)];
}
List<string> kinds = [];
if (_showResources)
{
kinds.Add(ResourcesLoc[nameof(Dashboard.Resources.Resources.ResourcesHeader)]);
}
if (_showParameters)
{
kinds.Add(Loc[nameof(Resources.Layout.NavMenuParametersTab)]);
}
if (_showTelemetryOnlyResources)
{
kinds.Add(Loc[nameof(Resources.Layout.NavMenuTelemetrySourcesTab)]);
}
return kinds.Count > 0 ? string.Join(", ", kinds) : Loc[nameof(Resources.Layout.ResourcePaneNoKindsShown)];
}
private ResourcePaneKind GetKind(string name) =>
ResourceViewModel.TryGetResourceByName(name, _resourceByName, out var resource)
? (resource.IsParameter ? ResourcePaneKind.Parameters : ResourcePaneKind.Resources)
: ResourcePaneKind.TelemetrySources;
private async Task ToggleKindAsync(ResourcePaneKind kind)
{
switch (kind)
{
case ResourcePaneKind.Resources:
_showResources = !_showResources;
break;
case ResourcePaneKind.Parameters:
_showParameters = !_showParameters;
break;
default:
_showTelemetryOnlyResources = !_showTelemetryOnlyResources;
break;
}
// Showing resources again adds row actions that may not fit the current width.
_fitPaneAfterInitialRender = true;
var shown = new List<string>();
if (_showResources)
{
shown.Add(nameof(ResourcePaneKind.Resources));
}
if (_showParameters)
{
shown.Add(nameof(ResourcePaneKind.Parameters));
}
if (_showTelemetryOnlyResources)
{
shown.Add(nameof(ResourcePaneKind.TelemetrySources));
}
await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourcePaneKinds, string.Join(',', shown));
// A kind that's switched off is no longer part of the selection.
var remaining = _selectedResourceNames.Where(name => IsKindShown(GetKind(name))).ToList();
if (remaining.Count != _selectedResourceNames.Count)
{
if (remaining.Count == 0)
{
await OnAllResourcesClickAsync();
}
else
{
NavigationManager.NavigateTo(GetSelectionUrl(remaining));
}
}
ResourcesChanged?.Invoke();
StateHasChanged();
}
private string GetPaneCountText(int resourceCount, int parameterCount, int telemetrySourceCount)
{
var resourceCountText = resourceCount > 0
? string.Format(CultureInfo.CurrentCulture,
Loc[resourceCount == 1 ? nameof(Resources.Layout.ResourcePaneCountSingular) : nameof(Resources.Layout.ResourcePaneCountPlural)],
resourceCount)
: null;
var parameterCountText = parameterCount > 0
? string.Format(CultureInfo.CurrentCulture,
Loc[parameterCount == 1 ? nameof(Resources.Layout.ResourcePaneParameterCountSingular) : nameof(Resources.Layout.ResourcePaneParameterCountPlural)],
parameterCount)
: null;
var telemetrySourceCountText = telemetrySourceCount > 0
? string.Format(CultureInfo.CurrentCulture,
Loc[telemetrySourceCount == 1 ? nameof(Resources.Layout.TelemetryPaneCountSingular) : nameof(Resources.Layout.TelemetryPaneCountPlural)],
telemetrySourceCount)
: null;
var counts = new[] { resourceCountText, parameterCountText, telemetrySourceCountText }
.OfType<string>()
.ToList();
if (counts.Count == 0)
{
return string.Format(CultureInfo.CurrentCulture, Loc[nameof(Resources.Layout.ResourcePaneCountPlural)], 0);
}
return counts.Skip(1).Aggregate(
counts[0],
(combined, count) => string.Format(CultureInfo.CurrentCulture, Loc[nameof(Resources.Layout.ResourcePaneMixedCount)], combined, count));
}
/// <summary>
/// Gets a value indicating whether an app model resource is one of the named resources. Names are either the
/// resource's unique name or, for resources without replicas, its display name.
/// </summary>
internal static bool IsResourceInSelection(ResourceViewModel resource, IReadOnlyCollection<string> resourceNames) =>
resourceNames.Contains(resource.Name, StringComparers.ResourceName) ||
resourceNames.Contains(resource.DisplayName, StringComparers.ResourceName);
/// <summary>
/// Adds the current multi-resource selection to a page URL, so changing page state such as filters keeps the
/// selection. A single selected resource is already part of the page's URL path.
/// </summary>
internal string AddSelectionToUrl(string url) => AddPaneToUrl(IsMultiSelection ? DashboardUrls.AddResourceSelection(url, _selectedResourceNames) : url);
internal bool IsTabAvailable(ResourceTab tab) => IsTabAvailable(tab, _selectedItems);
private bool IsTabAvailable(ResourceTab tab, IReadOnlyList<SelectedResourceItem> selection)
{
if (tab == ResourceTab.Terminal)
{
return !IsTelemetryPane && selection is [{ Resource: { } resource }] && CanViewResourceTerminal(resource);
}
if (tab == ResourceTab.Parameters)
{
return DashboardClient.IsEnabled &&
selection.Count == 0 &&
_resourceByName.Values.Any(r => r.IsParameter);
}
if (IsTelemetryPane && tab is ResourceTab.Overview or ResourceTab.Console)
{
return false;
}
if (selection.Count == 0)
{
// Without a selection the telemetry pages list every resource, so all of them are available.
return true;
}
// With several selected resources, a tab is available when any of them supports it.
return selection.Any(item => IsTabAvailable(tab, item));
}
private bool IsTabAvailable(ResourceTab tab, SelectedResourceItem item)
{
if (item.Resource is { } resource)
{
if (resource.IsParameter)
{
return tab == ResourceTab.Overview;
}
var telemetryResource = GetTelemetryResource(resource);
return tab switch
{
ResourceTab.Overview or ResourceTab.Console => true,
ResourceTab.Traces => telemetryResource is not null,
_ => telemetryResource is { UninstrumentedPeer: false }
};
}
if (item.TelemetryOnly is not null)
{
return tab is ResourceTab.StructuredLogs or ResourceTab.Traces or ResourceTab.Metrics;
}
// The resource isn't known (yet). Keep the tab so the page can report it.
return tab == CurrentTab;
}
internal static string GetTabUrl(ResourceTab tab, IReadOnlyList<string> resourceNames, string? activeResourceName = null)
{
if (resourceNames.Count > 1)
{
// The metrics page displays one resource's instruments at a time, and keeps the displayed resource in
// the URL path.
var url = tab switch
{
ResourceTab.Overview => DashboardUrls.ResourceOverviewUrl(),
ResourceTab.Parameters => DashboardUrls.ParametersUrl(),
ResourceTab.Console => DashboardUrls.ConsoleLogsUrl(),
ResourceTab.Terminal => DashboardUrls.TerminalsUrl(),
ResourceTab.StructuredLogs => DashboardUrls.StructuredLogsUrl(),
ResourceTab.Traces => DashboardUrls.TracesUrl(),
ResourceTab.Metrics => DashboardUrls.MetricsUrl(activeResourceName is not null && resourceNames.Contains(activeResourceName, StringComparers.ResourceName) ? activeResourceName : null),
_ => throw new InvalidOperationException($"Unexpected tab: {tab}")
};
return DashboardUrls.AddResourceSelection(url, resourceNames);
}
var resourceName = resourceNames.Count == 1 ? resourceNames[0] : null;
return tab switch
{
ResourceTab.Overview => DashboardUrls.ResourceOverviewUrl(resourceName),
ResourceTab.Parameters => resourceName is null
? DashboardUrls.ParametersUrl()
: DashboardUrls.AddResourceSelection(DashboardUrls.ParametersUrl(), [resourceName]),
ResourceTab.Console => DashboardUrls.ConsoleLogsUrl(resourceName),
ResourceTab.Terminal => resourceName is null ? DashboardUrls.TerminalsUrl() :
DashboardUrls.SetResourceTerminalFocus(DashboardUrls.ResourceOverviewUrl(resourceName), focused: true),
ResourceTab.StructuredLogs => DashboardUrls.StructuredLogsUrl(resourceName),
ResourceTab.Traces => DashboardUrls.TracesUrl(resourceName),
ResourceTab.Metrics => DashboardUrls.MetricsUrl(resourceName),
_ => throw new InvalidOperationException($"Unexpected tab: {tab}")
};
}
/// <summary>
/// Gets the URL that selects the given resources on the current tab, falling back to the overview when none of
/// the resources support the current tab. Metrics is kept for any selection but a single parameter.
/// </summary>
private string GetSelectionUrl(IReadOnlyList<string> resourceNames)
{
var selection = resourceNames.Select(name =>
{
if (ResourceViewModel.TryGetResourceByName(name, _resourceByName, out var resource))
{
return new SelectedResourceItem(name, resource, TelemetryOnly: null);
}
return new SelectedResourceItem(name, Resource: null, _telemetryOnlyResources.FirstOrDefault(r => string.Equals(r.Name, name, StringComparisons.ResourceName)));
}).ToList();
ResourceTab tab;
if (IsTabAvailable(CurrentTab, selection))
{
tab = CurrentTab;
}
else if (CurrentTab == ResourceTab.Metrics && selection is not [{ Resource: { IsParameter: true } }])
{
// Changing the selection on the metrics page never leaves it. The page reports resources without metrics.
tab = ResourceTab.Metrics;
}
else if (selection is [{ Resource: { IsParameter: true } }])
{
tab = ResourceTab.Overview;
}
else if (!IsTelemetryPane && (selection.Any(i => i.Resource is not null) || selection.Count == 0))
{
tab = ResourceTab.Overview;
}
else
{
// Resources that only send telemetry don't have an overview.
tab = ResourceTab.StructuredLogs;
}
return GetTabUrl(tab, resourceNames, RouteResourceName);
}
/// <summary>
/// Gets the URL a resource row links to, which selects only that resource.
/// </summary>
private string GetRowUrl(string resourceName) => GetSelectionUrl([resourceName]);
/// <summary>
/// Handles a click on a resource row. A click selects the resource, Ctrl+click (Cmd+click on macOS) adds or
/// removes it from the selection, and Shift+click selects the range of rows between the last clicked row and
/// this one.
/// </summary>
private async Task OnRowClickAsync(MouseEventArgs e, string resourceName)
{
// The selection can also change by navigating, for example with a link to a resource. Ranges then start at
// the last selected resource.
var anchor = _selectionAnchor is not null && IsSelected(_selectionAnchor) ? _selectionAnchor : _selectedResourceNames is [.., var last] ? last : null;
IReadOnlyList<string> selection;
if (e.ShiftKey && anchor is not null && GetVisibleRowNames() is var rowNames &&
rowNames.FindIndex(n => string.Equals(n, anchor, StringComparisons.ResourceName)) is var anchorIndex and >= 0 &&
rowNames.FindIndex(n => string.Equals(n, resourceName, StringComparisons.ResourceName)) is var clickedIndex and >= 0)
{
var start = Math.Min(anchorIndex, clickedIndex);
selection = rowNames.GetRange(start, Math.Abs(anchorIndex - clickedIndex) + 1);
_selectionAnchor = anchor;
}
else if (e.CtrlKey || e.MetaKey)
{
var toggled = GetToggleBaseSelection();
if (toggled.RemoveAll(n => string.Equals(n, resourceName, StringComparisons.ResourceName)) == 0)
{
toggled.Add(resourceName);
}
selection = toggled;
_selectionAnchor = resourceName;
}
else
{
if (_selectedResourceNames is [var only] && string.Equals(only, resourceName, StringComparisons.ResourceName))
{
// Selecting the only selected row again deselects it, like toggling.
await ClearSelectionAsync();
return;
}
selection = [resourceName];
_selectionAnchor = resourceName;
}
if (selection.Count == 0)
{
// Remember the cleared selection so the overview doesn't select a resource again.
await SetStoredSelectionAsync(selection);
}
NavigationManager.NavigateTo(GetSelectionUrl(selection));
}
// A single selected resource is shown by its row alone, so a one-resource group doesn't duplicate the highlight.
private bool IsResourceGroupSelected(List<string> groupResourceNames) =>
groupResourceNames.Count > 1 &&
groupResourceNames.Count == _selectedResourceNames.Count &&
groupResourceNames.All(IsSelected);
private async Task OnResourceGroupClick(MouseEventArgs e, List<string> groupResourceNames)
{
if (groupResourceNames.Count == 0)
{
return;
}
IReadOnlyList<string> selection;
if (e.CtrlKey || e.MetaKey)
{
// Like rows, a modifier click toggles the group's resources in the current selection.
var toggled = GetToggleBaseSelection();
if (groupResourceNames.All(n => toggled.Contains(n, StringComparers.ResourceName)))
{
toggled.RemoveAll(n => groupResourceNames.Contains(n, StringComparers.ResourceName));
}
else
{
toggled.AddRange(groupResourceNames.Where(n => !toggled.Contains(n, StringComparers.ResourceName)));
}
selection = toggled;
}
else
{
if (IsResourceGroupSelected(groupResourceNames))
{
await ClearSelectionAsync();
return;
}
selection = groupResourceNames;
}
_selectionAnchor = selection is [.., var last] ? last : null;
NavigationManager.NavigateTo(GetSelectionUrl(selection));
}
private async Task OnAllResourcesClickAsync()
{
_selectionAnchor = null;
// Remember the cleared selection so entering the overview later doesn't select a resource again.
await SetStoredSelectionAsync([]);
// On the overview, selecting everything shows the resource list. Other tabs stay where they are and list
// every resource.
NavigationManager.NavigateTo(DashboardClient.IsEnabled && CurrentTab == ResourceTab.Overview ? DashboardUrls.ResourceListUrl() : GetSelectionUrl([]));
}
/// <summary>
/// Gets the selection a Ctrl+click (Cmd+click on macOS) adds a resource to or removes it from. "Select all" is an
/// empty selection, and toggling a resource then starts from every resource it stands for, so the click deselects
/// that resource instead of selecting it alone.
/// </summary>
private List<string> GetToggleBaseSelection()
{
if (!IsAllSelected)
{
return _selectedResourceNames.ToList();
}
// Resources that only send telemetry are listed in their own section, and are part of everything too.
var names = GetAllResourceNames();
if (DashboardClient.IsEnabled)
{
names.AddRange(_telemetryOnlyResources.Select(r => r.Name).Where(n => !names.Contains(n, StringComparers.ResourceName)));
}
return names;
}
/// <summary>
/// Deselects everything without leaving the current tab, e.g. clearing a selection on Metrics stays on Metrics.
/// </summary>
private async Task ClearSelectionAsync()
{
_selectionAnchor = null;
await SetStoredSelectionAsync([]);
NavigationManager.NavigateTo(GetSelectionUrl([]));
}
/// <summary>
/// Gets the names of the resources that "All resources" stands for, in the order of the resource list. The pane's
/// filters don't apply because they only narrow down the list.
/// </summary>
internal List<string> GetAllResourceNames()
{
if (!DashboardClient.IsEnabled)
{
return _telemetryOnlyResources.Select(r => r.Name).ToList();
}
var names = _resourceByName.Values
.Where(r => !r.IsResourceHidden(_filter.ShowHiddenResources))
.OrderBy(r => r.ResourceType)
.ThenBy(r => r, ResourceViewModelNameComparer.Instance)
.Select(GetResourceName)
.ToList();
return names;
}
private List<string> GetVisibleRowNames()
{
List<string> names;
if (DashboardClient.IsEnabled && _grouping != ResourcePaneGrouping.None)
{
// A resource with several tags is listed several times. Ranges use its first position.
names = GetResourceGroups()
.Where(g => !IsGroupCollapsed(g))
.SelectMany(g => g.Resources)
.Select(GetResourceName)
.Distinct(StringComparers.ResourceName)
.ToList();
}
else
{
names = IsSectionCollapsed(ResourcesSectionKey)
? []
: GetPaneRows().Select(r => GetResourceName(r.Resource)).ToList();
if (!IsSectionCollapsed(ParametersSectionKey))
{
names.AddRange(GetParameterPaneRows().Select(GetResourceName));
}
}
if (!IsSectionCollapsed(TelemetrySectionKey))
{
names.AddRange(GetVisibleTelemetryResources().Select(r => r.Name));
}
return names;
}
private bool IsSelected(string resourceName) => _selectedResourceNames.Contains(resourceName, StringComparers.ResourceName);
// An empty selection means everything is selected, so every row is shown selected. Only the look follows: the
// rows aren't each announced as current, "Select all" being pressed already says it.
private bool IsAllSelected => _selectedResourceNames.Count == 0;
private string? GetRowAriaCurrent(bool isSelected) => isSelected ? (IsMultiSelection ? "true" : "page") : null;
private static string GetTabId(ResourceTab tab) => $"tab-{tab}";
private void OnTabChanged(FluentTab? newTab, bool isDesktop, string? renderedLocation)
{
// FluentTabs also raises this when a tab is disposed, for example when leaving the resources view, and
// when it syncs with ActiveTabId after a navigation. Only a change made while this layout shows the current
// URL is a user selecting a tab.
// Switching between the live view and a recording re-creates the page, and the outgoing layout's tabs
// raise changes while they're torn down, which would otherwise navigate to another tab.
if (_isDisposed
|| isDesktop != ViewportInformation.IsDesktop
// A delayed event from the previous header must not navigate the newly selected resource.
|| !string.Equals(renderedLocation, _lastLocation, StringComparison.Ordinal)
|| !string.Equals(NavigationManager.Uri, _lastLocation, StringComparison.Ordinal)
|| newTab?.Id is not { } id
|| !id.StartsWith("tab-", StringComparison.Ordinal)
|| !Enum.TryParse<ResourceTab>(id["tab-".Length..], out var tab)
|| tab == CurrentTab
// A navigation started from the tab strip is still in flight. The layout re-renders when this handler
// returns, before the router reaches the new location, and that render must not put the previous tab
// back: the strip would report it as a change once the new page shows, and navigate back to it, forever.
|| _navigatingToTab is not null)
{
return;
}
// Tabs map to pages, so a tab change is a navigation. The tab URL keeps the current resource selection.
if (GetTabs().FirstOrDefault(t => t.Tab == tab && t.IsVisible)?.Href is { } href)
{
_navigatingToTab = tab;
NavigationManager.NavigateTo(href);
}
}
private IEnumerable<TabItem> GetTabs()
{
if (CurrentTab == ResourceTab.Parameters)
{
yield return CreateTab(ResourceTab.Parameters, Loc[nameof(Resources.Layout.NavMenuParametersTab)], new Icons.Regular.Size24.Key(), isVisible: true);
yield break;
}
var resource = SelectedResource;
var isTelemetryOnly = SelectedTelemetryOnlyResource is not null;
var isMultiSelection = IsMultiSelection;
// Every tab is always rendered and hidden when it doesn't apply. Removing a FluentTab makes FluentTabs
// activate its first tab, which would navigate away from the current page.
var errorCount = _selectedItems.Sum(i => i.Resource is { } r ? GetUnviewedErrorCount(r) : 0);
yield return CreateTab(ResourceTab.Overview, Loc[nameof(Resources.Layout.ResourceTabOverview)], new Icons.Regular.Size24.Board(),
isVisible: DashboardClient.IsEnabled && !isTelemetryOnly && (resource is not null || isMultiSelection || _selectedItems.Count == 0));
yield return CreateTab(ResourceTab.Console, Loc[nameof(Resources.Layout.NavMenuConsoleLogsTab)], new Icons.Regular.Size24.SlideText(),
isVisible: DashboardClient.IsEnabled && !isTelemetryOnly);
yield return CreateTab(ResourceTab.Terminal, TerminalLoc[nameof(TerminalStrings.TerminalTitle)], new Icons.Regular.Size20.WindowConsole(),
isVisible: IsTabAvailable(ResourceTab.Terminal) || CurrentTab == ResourceTab.Terminal);
yield return CreateTab(ResourceTab.StructuredLogs, StructuredLogsLoc[nameof(Resources.StructuredLogs.StructuredLogsHeader)], new Icons.Regular.Size24.SlideTextSparkle(),
isVisible: true, errorCount);
yield return CreateTab(ResourceTab.Traces, Loc[nameof(Resources.Layout.NavMenuTracesTab)], new Icons.Regular.Size24.GanttChart(),
isVisible: true);
yield return CreateTab(ResourceTab.Metrics, Loc[nameof(Resources.Layout.NavMenuMetricsTab)], new Icons.Regular.Size24.ChartMultiple(),
isVisible: true);
TabItem CreateTab(ResourceTab tab, string text, Icon icon, bool isVisible, int errorCount = 0)
{
// Tabs are never disabled. A tab that doesn't apply to the selection still navigates, and the page explains why it is empty.
return new TabItem(tab, text, icon, isVisible, GetTabUrl(tab), errorCount, IsTabAvailable(tab));
}
}
// Mirrors GetTabs() as a dropdown for narrow layouts, where the FluentTabs row no longer fits and would
// otherwise need its own horizontal scrollbar (see the "resource-tabs-nav" container query in the stylesheet).
private IList<MenuButtonItem> GetTabMenuItems()
{
var items = new List<MenuButtonItem>();
foreach (var tab in GetTabs())
{
if (!tab.IsVisible)
{
continue;
}
items.Add(new MenuButtonItem
{
Id = $"{GetTabId(tab.Tab)}-menu-item",
Class = "resource-tab-menu-item",
Text = tab.Text,
StartIcon = tab.Icon,
Role = MenuItemRole.Radio,
Checked = tab.Tab == CurrentTab,
Tooltip = tab.IsAvailable ? null : Loc[nameof(Resources.Layout.ResourceTabUnavailable)].Value,
OnClick = () => { NavigationManager.NavigateTo(tab.Href); return Task.CompletedTask; }
});
}
return items;
}
internal static string GetStateClass(ResourceViewModel resource) =>
resource.IsParameter && resource.HasMissingParameterValueState()
? "state-warning"
: resource.KnownState switch
{
KnownResourceState.Running when resource.HealthStatus is Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy => "state-warning",
KnownResourceState.Running => "state-success",
KnownResourceState.FailedToStart or KnownResourceState.RuntimeUnhealthy => "state-error",
KnownResourceState.Exited when resource.TryGetExitCode(out var exitCode) && exitCode is not 0 => "state-error",
KnownResourceState.Starting or KnownResourceState.Building or KnownResourceState.Waiting or KnownResourceState.Stopping => "state-pending",
_ => "state-neutral"
};
public async ValueTask DisposeAsync()
{
_isDisposed = true;
_cts.Cancel();
TagStore.Changed -= OnTagsChanged;
ViewPreferences.Changed -= OnViewPreferencesChanged;
OrderStore.Changed -= OnOrderChanged;
_logsSubscription?.Dispose();
_telemetryResourcesSubscription?.Dispose();
await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask);
await TaskHelpers.WaitIgnoreCancelAsync(_ageRefreshTask);
_cts.Dispose();
if (_jsModule is { } module)
{
try
{
await module.InvokeVoidAsync("unregisterPaneResizer", _layoutElement);
await module.InvokeVoidAsync("unregisterResourceOrdering", _layoutElement);
await module.InvokeVoidAsync("unregisterTabOverflow");
}
catch (JSDisconnectedException)
{
// The circuit is gone, so the browser has already discarded the handlers.
}
await JSInteropHelpers.SafeDisposeAsync(module);
}
_selfRef?.Dispose();
}
internal sealed record ResourceLocation(ResourceTab Tab, string? RouteResourceName, IReadOnlyList<string> SelectedResourceNames, bool KeepSelection);
private sealed record TelemetryOnlyResource(string Name, OtlpResource Resource);
private sealed record SelectedResourceItem(string Name, ResourceViewModel? Resource, TelemetryOnlyResource? TelemetryOnly);
private sealed record ResourceGroup(string? Value, List<ResourceViewModel> Resources);
private sealed record TabItem(ResourceTab Tab, string Text, Icon Icon, bool IsVisible, string Href, int ErrorCount, bool IsAvailable);
}
// These enums are top-level rather than nested in ResourcesLayout because pages reference the layout through
// [Layout(typeof(ResourcesLayout))], which preserves all of the layout's members for trimming. Native AOT reports
// every preserved nested enum as a use of Enum.GetValues(Type) (IL3050) on each page that uses the layout.
internal enum ResourceTab
{
Overview,
Parameters,
Console,
Terminal,
StructuredLogs,
Traces,
Metrics
}
internal enum ResourcePaneKind
{
Resources,
Parameters,
TelemetrySources
}
internal enum ResourcePaneGrouping
{
None,
Tags,
ResourceType,
State
}