File: Components\Layout\NavigationAvailabilityProvider.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Storage;
using Microsoft.AspNetCore.Components;
 
namespace Aspire.Dashboard.Components.Layout;
 
/// <summary>
/// Watches the live run's resources and telemetry to report which optional navigation entries have something to
/// show: terminals, parameters and telemetry sources. It's rendered by the main layout because the navigation is
/// shown on every page, not only those that already watch resources.
/// </summary>
public sealed class NavigationAvailabilityProvider : ComponentBase, IAsyncDisposable
{
    private readonly CancellationTokenSource _cts = new();
    private readonly Dictionary<string, ResourceViewModel> _resources = new(StringComparers.ResourceName);
    private Subscription? _telemetryResourcesSubscription;
    private Task? _watchTask;
    private NavigationAvailability? _reported;
    private bool _resourcesLoaded;
    private bool _disposed;
 
    [Inject]
    public required IDashboardClient DashboardClient { get; init; }
 
    // The live run's telemetry. The provider is only rendered for the live run (see MainLayout).
    [Inject]
    public required ITelemetryRepository TelemetryRepository { get; init; }
 
    [Inject]
    public required ILogger<NavigationAvailabilityProvider> Logger { get; init; }
 
    [Parameter, EditorRequired]
    public required EventCallback<NavigationAvailability> AvailabilityChanged { get; set; }
 
    protected override void OnInitialized()
    {
        if (DashboardClient.IsEnabled && !DashboardClient.IsReadOnly)
        {
            _telemetryResourcesSubscription = TelemetryRepository.OnNewResources(() => InvokeAsync(() => ReportAvailabilityAsync(_cts.Token)));
            _watchTask = WatchAsync(_cts.Token);
        }
    }
 
    private async Task WatchAsync(CancellationToken cancellationToken)
    {
        try
        {
            await DashboardClient.WhenConnected.WaitAsync(cancellationToken);
            var (snapshot, updates) = await DashboardClient.SubscribeResourcesAsync(cancellationToken);
            await InvokeAsync(async () =>
            {
                foreach (var resource in snapshot)
                {
                    _resources[resource.Name] = resource;
                }
                _resourcesLoaded = true;
                await ReportAvailabilityAsync(cancellationToken);
            });
 
            await foreach (var changes in updates.WithCancellation(cancellationToken))
            {
                await InvokeAsync(async () =>
                {
                    foreach (var (changeType, resource) in changes)
                    {
                        if (changeType == ResourceViewModelChangeType.Delete)
                        {
                            _resources.Remove(resource.Name);
                        }
                        else
                        {
                            _resources[resource.Name] = resource;
                        }
                    }
                    await ReportAvailabilityAsync(cancellationToken);
                });
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Failed to watch navigation availability.");
            await DispatchExceptionAsync(ex);
        }
    }
 
    private async Task ReportAvailabilityAsync(CancellationToken cancellationToken)
    {
        // Until the app model is loaded, every telemetry resource would look like a telemetry source and there would
        // be no parameters. Keep the entries as they are rather than disabling them for a moment on every page load.
        if (cancellationToken.IsCancellationRequested || !_resourcesLoaded)
        {
            return;
        }
 
        var availability = new NavigationAvailability(
            HasResourceTerminals: _resources.Values.Any(ResourceSelectHelpers.HasUsableTerminal),
            HasParameters: _resources.Values.Any(r => r.IsParameter),
            HasTelemetrySources: HasTelemetrySources());
        if (_reported != availability)
        {
            _reported = availability;
            await AvailabilityChanged.InvokeAsync(availability);
        }
    }
 
    // Telemetry sources are the resources that send telemetry without being part of the app model (for example a
    // browser app), the same ones the telemetry pane lists.
    private bool HasTelemetrySources()
    {
        var telemetryResources = TelemetryRepository.GetResources();
        if (telemetryResources.Count == 0)
        {
            return false;
        }
 
        var appModelKeys = new HashSet<ResourceKey>();
        foreach (var resource in _resources.Values)
        {
            if (TelemetryRepository.GetResourceByCompositeName(resource.Name) is { } otlpResource)
            {
                appModelKeys.Add(otlpResource.ResourceKey);
            }
        }
 
        return telemetryResources.Any(r => !appModelKeys.Contains(r.ResourceKey));
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
        _disposed = true;
        _telemetryResourcesSubscription?.Dispose();
        await _cts.CancelAsync();
        if (_watchTask is not null)
        {
            await _watchTask;
        }
        _cts.Dispose();
    }
}
 
/// <summary>
/// Which optional navigation entries have something to show.
/// </summary>
public readonly record struct NavigationAvailability(bool HasResourceTerminals, bool HasParameters, bool HasTelemetrySources)
{
    /// <summary>
    /// Used until the live run's app model is loaded, and for runs that aren't watched: entries that are always
    /// listed stay enabled, and terminals, which are only listed when available, stay hidden.
    /// </summary>
    public static NavigationAvailability Default { get; } = new(HasResourceTerminals: false, HasParameters: true, HasTelemetrySources: true);
}