| File: Components\Controls\TerminalDisconnectedBanner.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 Aspire.Dashboard.Model; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; using Microsoft.FluentUI.AspNetCore.Components; namespace Aspire.Dashboard.Components.Controls; /// <summary> /// Shows recovery actions without discarding the disconnected terminal's output. /// </summary> public sealed partial class TerminalDisconnectedBanner : ComponentBase, IAsyncDisposable { private readonly CancellationTokenSource _cts = new(); private readonly Dictionary<string, ResourceViewModel> _resources = new(StringComparers.ResourceName); private Task? _watchTask; private bool _executing; private bool _disposed; private bool _collapsed; private bool _focusToggle; private FluentButton? _toggleButton; /// <summary>Gets or sets the exact resource instance that owns this terminal.</summary> [Parameter] public string? ResourceName { get; set; } /// <summary>Gets or sets whether resource commands are unavailable in this view.</summary> [Parameter] public bool ReadOnly { get; set; } /// <summary>Gets or sets whether the disconnected renderer can no longer display its output.</summary> [Parameter] public bool OutputUnavailable { get; set; } /// <summary>Raised when the user closes the owning dock tab.</summary> [Parameter] public EventCallback OnClose { get; set; } /// <summary>Raised after a resource starts or restarts successfully so its terminal can reconnect.</summary> [Parameter] public EventCallback OnRestarted { get; set; } /// <summary>Raised to retry an exhausted transport connection without restarting the resource.</summary> [Parameter] public EventCallback OnReconnect { get; set; } /// <summary>Raised when the banner is collapsed or expanded over the retained output.</summary> [Parameter] public EventCallback<bool> OnCollapsedChanged { get; set; } [Inject] public required IDashboardClient DashboardClient { get; init; } [Inject] public required DashboardDataSource DataSource { get; init; } [Inject] public required DashboardCommandExecutor CommandExecutor { get; init; } [Inject] public required IStringLocalizer<Resources.TerminalStrings> Loc { get; init; } [Inject] public required ILogger<TerminalDisconnectedBanner> Logger { get; init; } private ResourceViewModel? Resource => ResourceName is not null ? _resources.GetValueOrDefault(ResourceName) : null; private int? ExitCode => Resource is { } resource && resource.TryGetExitCode(out var exitCode) ? exitCode : null; private string Message => OutputUnavailable ? Loc[nameof(Resources.TerminalStrings.TerminalMountFailed)].Value : ExitCode is { } exitCode ? Loc[nameof(Resources.TerminalStrings.TerminalDisconnectedWithExitCode), exitCode].Value : Loc[nameof(Resources.TerminalStrings.TerminalDisconnectedMessage)].Value; private string ShowBannerLabel => ExitCode is { } exitCode ? Loc[nameof(Resources.TerminalStrings.TerminalShowDisconnectedBannerWithExitCode), exitCode].Value : Loc[nameof(Resources.TerminalStrings.TerminalShowDisconnectedBanner)].Value; private CommandViewModel? RestartCommand { get { if (ReadOnly || DashboardClient.IsReadOnly || Resource is not { } resource) { return null; } // Finished resources commonly expose only Start. Use the advertised // command rather than synthesizing Restart, which the server may reject. var commands = resource.Commands.Where(c => c.State != CommandViewModelState.Hidden && c.Name is CommandViewModel.RestartCommand or CommandViewModel.StartCommand); return commands.OrderBy(c => c.State != CommandViewModelState.Enabled) .ThenBy(c => c.Name != CommandViewModel.RestartCommand).FirstOrDefault(); } } private bool RestartDisabled => _executing || RestartCommand is not { State: CommandViewModelState.Enabled } command || Resource is not { } resource || CommandExecutor.IsExecuting(resource.Name, command.Name); protected override void OnInitialized() { if (ResourceName is not null && DashboardClient.IsEnabled) { _watchTask = WatchResourcesAsync(_cts.Token); } } protected override async Task OnAfterRenderAsync(bool firstRender) { if (_focusToggle && _toggleButton is not null) { _focusToggle = false; await _toggleButton.Element.FocusAsync(preventScroll: true); } } private async Task SetCollapsedAsync(bool collapsed) { _collapsed = collapsed; _focusToggle = true; await OnCollapsedChanged.InvokeAsync(collapsed); } private async Task WatchResourcesAsync(CancellationToken cancellationToken) { try { await DashboardClient.WhenResourcesReady.WaitAsync(cancellationToken); var (snapshot, updates) = await DataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); await InvokeAsync(() => { if (cancellationToken.IsCancellationRequested) { return; } foreach (var resource in snapshot) { _resources[resource.Name] = resource; } StateHasChanged(); }); await foreach (var changes in updates.WithCancellation(cancellationToken)) { await InvokeAsync(() => { if (cancellationToken.IsCancellationRequested) { return; } foreach (var (changeType, resource) in changes) { if (changeType == ResourceViewModelChangeType.Delete) { _resources.Remove(resource.Name); } else { _resources[resource.Name] = resource; } } StateHasChanged(); }); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception ex) { Logger.LogError(ex, "Failed to watch the disconnected terminal resource."); await DispatchExceptionAsync(ex); } } private async Task RestartResourceAsync() { if (RestartDisabled || Resource is not { } resource || RestartCommand is not { } command) { Logger.LogDebug("The disconnected terminal's restart command is no longer available."); return; } _executing = true; try { // The executor uses this callback off the renderer thread, where the resource watch // may be changing the dictionary. Resolve the name before yielding. var resourceName = ResourceViewModel.GetResourceName(resource, _resources); if (await CommandExecutor.ExecuteAsync(resource, command, _ => resourceName) && !_disposed) { await OnRestarted.InvokeAsync(); } } finally { _executing = false; } } private async Task CloseAsync() { if (_executing) { return; } _executing = true; try { await OnClose.InvokeAsync(); } finally { _executing = false; } } /// <inheritdoc /> public async ValueTask DisposeAsync() { if (_disposed) { return; } _disposed = true; await _cts.CancelAsync(); if (_watchTask is not null) { await _watchTask; } _cts.Dispose(); } }