File: Components\Pages\Terminals.razor.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 System.Collections.Concurrent;
using System.Collections.Immutable;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
 
namespace Aspire.Dashboard.Components.Pages;
 
public sealed partial class Terminals : ComponentBase, IAsyncDisposable, IComponentWithTelemetry,
    IPageWithSessionAndUrlState<Terminals.TerminalsViewModel, Terminals.TerminalsPageState>
{
    private readonly CancellationTokenSource _cts = new();
    // URL restoration can read resource names off the renderer while the watch updates this map.
    private readonly ConcurrentDictionary<string, ResourceViewModel> _resourceByName = new(StringComparers.ResourceName);
    private ImmutableList<SelectViewModel<ResourceTypeDetails>>? _resources;
    private readonly List<CommandViewModel> _highlightedCommands = [];
    private readonly List<MenuButtonItem> _resourceMenuItems = [];
    private readonly List<MenuButtonItem> _optionsMenuItems = [];
    private AspirePageContentLayout? _contentLayout;
    private Task? _watchTask;
    private bool _showHiddenResources;
    private bool _redirected;
    private bool _disposed;
 
    [Inject]
    public required DashboardDataSource DataSource { get; init; }
 
    [Inject]
    public required IDashboardClient DashboardClient { get; init; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; init; }
 
    [Inject]
    public required ISessionStorage SessionStorage { get; init; }
 
    [Inject]
    public required IStringLocalizer<TerminalStrings> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<ControlsStrings> ControlsLoc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.ConsoleLogs> ConsoleLoc { get; init; }
 
    [Inject]
    public required ILogger<Terminals> Logger { get; init; }
 
    [Inject]
    public required ResourceMenuBuilder ResourceMenuBuilder { get; init; }
 
    [Inject]
    public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }
 
    [Inject]
    public required IconResolver IconResolver { get; init; }
 
    [Inject]
    public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }
 
    [CascadingParameter]
    public required ViewportInformation ViewportInformation { get; init; }
 
    [Parameter]
    public string? ResourceName { get; set; }
 
    public string BasePath => DashboardUrls.TerminalsBasePath;
    public string SessionStorageKey => BrowserStorageKeys.TerminalsPageState;
    public TerminalsViewModel PageViewModel { get; set; } = new();
    public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.Terminals);
 
    protected override async Task OnInitializedAsync()
    {
        TelemetryContextProvider.Initialize(TelemetryContext);
        if (!DashboardClient.IsEnabled || DashboardClient.IsReadOnly)
        {
            RedirectToResources();
            return;
        }
 
        var cancellationToken = _cts.Token;
        try
        {
            var hidden = await SessionStorage.GetAsync<bool>(BrowserStorageKeys.ResourcesShowHiddenResources).WaitAsync(cancellationToken);
            cancellationToken.ThrowIfCancellationRequested();
            _showHiddenResources = hidden.Success && hidden.Value;
            // Connection readiness precedes persistence of the initial resource snapshot.
            await DashboardClient.WhenResourcesReady.WaitAsync(cancellationToken);
            cancellationToken.ThrowIfCancellationRequested();
            var (snapshot, updates) = await DataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken);
            cancellationToken.ThrowIfCancellationRequested();
            foreach (var resource in snapshot)
            {
                _resourceByName[resource.Name] = resource;
            }
            UpdateResources();
            _watchTask = WatchResourcesAsync(updates, cancellationToken);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
    }
 
    protected override async Task OnParametersSetAsync()
    {
        if (_disposed || _redirected || _resources is null)
        {
            return;
        }
 
        if (!_resourceByName.Values.Any(ResourceSelectHelpers.HasUsableTerminal))
        {
            RedirectToResources();
            return;
        }
 
        if (await this.InitializeViewModelAsync())
        {
            return;
        }
        UpdateMenus();
    }
 
    private async Task WatchResourcesAsync(IAsyncEnumerable<IReadOnlyList<ResourceViewModelChange>> updates, CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var changes in updates.WithCancellation(cancellationToken))
            {
                await InvokeAsync(async () =>
                {
                    if (cancellationToken.IsCancellationRequested || _redirected)
                    {
                        return;
                    }
 
                    var previousSelection = PageViewModel.SelectedResource?.Id?.InstanceId;
                    foreach (var (changeType, resource) in changes)
                    {
                        if (changeType == ResourceViewModelChangeType.Delete)
                        {
                            _resourceByName.TryRemove(resource.Name, out _);
                        }
                        else
                        {
                            _resourceByName[resource.Name] = resource;
                        }
                    }
                    UpdateResources();
                    if (!_resourceByName.Values.Any(ResourceSelectHelpers.HasUsableTerminal))
                    {
                        RedirectToResources();
                        return;
                    }
 
                    PageViewModel.SelectedResource = FindResource(previousSelection);
                    UpdateMenus();
                    if (!string.Equals(previousSelection, PageViewModel.SelectedResource?.Id?.InstanceId, StringComparisons.ResourceName))
                    {
                        await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false);
                    }
                    StateHasChanged();
                });
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Failed to watch terminal resources.");
            await DispatchExceptionAsync(ex);
        }
    }
 
    private void UpdateResources()
    {
        var terminals = _resourceByName.Values.Where(ResourceSelectHelpers.HasUsableTerminal)
            .ToDictionary(r => r.Name, StringComparers.ResourceName);
        _resources = ResourceSelectHelpers.CreateOptions(terminals, ConsoleLoc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsUnknownState)], _showHiddenResources)
            .Insert(0, new() { Id = null, Name = ControlsLoc[nameof(ControlsStrings.LabelNone)] });
    }
 
    private SelectViewModel<ResourceTypeDetails>? FindResource(string? name)
    {
        if (_resources is null)
        {
            return null;
        }
 
        return _resources.GetResource(Logger, name, canSelectGrouping: false, fallbackViewModel: _resources[0]);
    }
 
    public async Task UpdateViewModelFromQueryAsync(TerminalsViewModel viewModel)
    {
        viewModel.SelectedResource = FindResource(ResourceName);
        var url = GetUrlFromSerializableViewModel(ConvertViewModelToSerializable());
        await SessionStorage.SetAsync(SessionStorageKey, ConvertViewModelToSerializable());
        if (NavigationManager.ToBaseRelativePath(NavigationManager.Uri) != url.TrimStart('/'))
        {
            NavigationManager.NavigateTo(url, new NavigationOptions { ReplaceHistoryEntry = true });
        }
    }
 
    public string GetUrlFromSerializableViewModel(TerminalsPageState serializable)
    {
        var resource = FindResource(serializable.SelectedResource);
        return DashboardUrls.TerminalsUrl(GetResourceName(resource));
    }
 
    public TerminalsPageState ConvertViewModelToSerializable()
        => new(GetResourceName(PageViewModel.SelectedResource));
 
    private string? GetResourceName(SelectViewModel<ResourceTypeDetails>? selectedResource)
        => selectedResource?.Id?.InstanceId is { } name && _resourceByName.TryGetValue(name, out var resource)
            ? ResourceViewModel.GetResourceName(resource, _resourceByName)
            : null;
 
    private ResourceViewModel? GetSelectedResource()
        => PageViewModel.SelectedResource?.Id?.InstanceId is { } name
            ? _resourceByName.GetValueOrDefault(name)
            : null;
 
    private async Task HandleSelectedResourceChangedAsync()
    {
        UpdateMenus();
        await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false);
    }
 
    private void UpdateMenus()
    {
        _highlightedCommands.Clear();
        _resourceMenuItems.Clear();
        _optionsMenuItems.Clear();
        CommonMenuItems.AddToggleHiddenResourcesMenuItem(_optionsMenuItems, ControlsLoc, _showHiddenResources,
            _resourceByName.Values.Where(ResourceSelectHelpers.HasUsableTerminal), SessionStorage,
            EventCallback.Factory.Create<bool>(this, async showHidden =>
            {
                _showHiddenResources = showHidden;
                var previousSelection = PageViewModel.SelectedResource?.Id?.InstanceId;
                UpdateResources();
                PageViewModel.SelectedResource = FindResource(previousSelection);
                UpdateMenus();
                await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false);
            }));
 
        if (GetSelectedResource() is { } resource)
        {
            if (ViewportInformation.IsDesktop)
            {
                _highlightedCommands.AddRange(resource.Commands.Where(c => c.IsHighlighted && c.State != CommandViewModelState.Hidden)
                    .Take(DashboardUIHelpers.MaxHighlightedCommands));
            }
            ResourceMenuBuilder.AddMenuItems(_resourceMenuItems, resource, _resourceByName,
                EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(resource: resource.Name))),
                EventCallback.Factory.Create<CommandViewModel>(this, ExecuteResourceCommandAsync),
                (r, command) => DashboardCommandExecutor.IsExecuting(r.Name, command.Name),
                showViewDetails: true, showConsoleLogsItem: true, showUrls: true);
        }
    }
 
    private Task ExecuteResourceCommandAsync(CommandViewModel command)
    {
        if (GetSelectedResource() is not { } resource)
        {
            Logger.LogWarning("No terminal resource selected for command execution.");
            return Task.CompletedTask;
        }
 
        return DashboardCommandExecutor.ExecuteAsync(resource, command, r => ResourceViewModel.GetResourceName(r, _resourceByName));
    }
 
    private void RedirectToResources()
    {
        _redirected = true;
        NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(), new NavigationOptions { ReplaceHistoryEntry = true });
    }
 
    public void UpdateTelemetryProperties()
    {
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
        _disposed = true;
        await _cts.CancelAsync();
        if (_watchTask is not null)
        {
            await _watchTask;
        }
        _cts.Dispose();
        TelemetryContext.Dispose();
    }
 
    public sealed class TerminalsViewModel
    {
        public SelectViewModel<ResourceTypeDetails>? SelectedResource { get; set; }
    }
 
    public sealed record TerminalsPageState(string? SelectedResource);
}