| File: Components\Pages\TerminalWindow.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.Components.Controls; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Pages; /// <summary> /// Renders a single terminal as an entire browser window, with no dashboard chrome around it. /// </summary> /// <remarks> /// <para> /// This is what the dashboard opens when the user detaches a terminal. Because terminals are multi-headed, the window /// is just another viewer: it reaches the dashboard on its own and keeps working after the page that spawned it is /// reloaded or closed. The terminal target is selected once when the window initializes. /// </para> /// <para> /// Opening the window requests primary once and fits the grid at the opener's selected font size. While primary, /// the window resizes the grid to its viewport without changing that font size. /// </para> /// </remarks> public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable { private string? _endpoint; private string _title = string.Empty; private TerminalToolbarState _terminalState = new(); private Icon? _resourceIcon; private string? _resourceInstanceName; private bool _ended; private bool _disposed; private readonly CancellationTokenSource _cts = new(); private Task _watchTask = Task.CompletedTask; private IJSObjectReference? _windowModule; private DotNetObjectReference<TerminalWindow>? _windowReference; private Task? _windowRegistrationTask; private string? _windowRegistrationId; private bool _windowReady = true; private bool _windowTrackingFailed; /// <summary> /// Gets or sets the id of an AppHost-owned dock terminal to attach to. /// </summary> [Parameter] public string? TerminalId { get; set; } /// <summary> /// Gets or sets the resource name used by the Terminals page: a singleton display name or replica instance name. /// </summary> [Parameter] public string? ResourceName { get; set; } /// <summary>Gets or sets the font size carried from the terminal's originating surface.</summary> [SupplyParameterFromQuery(Name = "fontSize")] public int? FontSize { get; set; } /// <summary>Gets or sets the opener identity carried by a coordinated dock window.</summary> [SupplyParameterFromQuery(Name = "windowOwner")] public string? WindowOwner { get; set; } /// <summary>Gets or sets the detachment generation carried by a coordinated dock window.</summary> [SupplyParameterFromQuery(Name = "windowGeneration")] public string? WindowGeneration { get; set; } [Inject] public required IJSRuntime JS { get; init; } [Inject] public required NavigationManager NavigationManager { get; init; } [Inject] public required IDashboardClient DashboardClient { get; init; } [Inject] public required IconResolver IconResolver { get; init; } [Inject] public required IStringLocalizer<Dashboard.Resources.TerminalStrings> Loc { get; init; } [Inject] public required ILogger<TerminalWindow> Logger { get; init; } protected override async Task OnInitializedAsync() { var terminalId = TerminalId is { Length: > 0 } ? TerminalId : null; var resourceName = terminalId is null && ResourceName is { Length: > 0 } ? ResourceName : null; var cancellationToken = _cts.Token; // Don't mount a resource viewer until its exact instance has been resolved. _windowReady = resourceName is null && (terminalId is null || (WindowOwner is null && WindowGeneration is null)); _endpoint = terminalId is not null ? $"api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}" : null; _title = terminalId ?? resourceName ?? string.Empty; ResourceViewModel? resource = null; if (resourceName is not null && DashboardClient.IsEnabled) { try { await DashboardClient.WhenResourcesReady.WaitAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } if (_disposed) { return; } resource = DashboardClient.GetResource(resourceName); if (resource is null) { // Singleton URLs retain the display name (e.g. "shell") across restarts, // while the generated instance name (e.g. "shell-abc123") can change. var resources = DashboardClient.GetResources().ToDictionary(r => r.Name, StringComparers.ResourceName); ResourceViewModel.TryGetResourceByName(resourceName, resources, out resource); } } if (resource is not null && resource.HasTerminal()) { _resourceInstanceName = resource.Name; _resourceIcon = ResourceIconHelpers.GetIconForResource(IconResolver, resource, IconSize.Size16); } else if (resourceName is not null) { _ended = true; Logger.LogWarning("Could not resolve terminal resource {ResourceName}.", resourceName); } _windowReady = terminalId is null || (WindowOwner is null && WindowGeneration is null); if (terminalId is null) { return; } // Resource metadata is a one-time lookup; only AppHost terminals need updates. _watchTask = Task.Run(() => WatchTerminalsAsync(terminalId, cancellationToken)); } private void OnTerminalToolbarStateChanged(TerminalToolbarState state) => _terminalState = state; protected override async Task OnAfterRenderAsync(bool firstRender) { if (_disposed) { return; } if (_ended) { await StopWindowTrackingAsync(release: true); } else if (!_windowReady && !_windowTrackingFailed && _windowRegistrationTask is null && TerminalId is { } terminalId) { _windowRegistrationTask = RegisterWindowAsync(terminalId); await _windowRegistrationTask; } } private async Task RegisterWindowAsync(string terminalId) { try { var moduleUri = new Uri(new Uri(NavigationManager.BaseUri), Assets["js/app-terminalwindow.js"]); _windowModule ??= await JS.InvokeAsync<IJSObjectReference>("import", moduleUri.PathAndQuery); if (_disposed) { return; } _windowReference ??= DotNetObjectReference.Create(this); var id = _windowRegistrationId = Guid.NewGuid().ToString("N"); var ready = await _windowModule.InvokeAsync<bool>("registerDetachedTerminalWindow", id, terminalId, NavigationManager.BaseUri, _windowReference); if (!_disposed && !_windowTrackingFailed) { // A reload must check durable revocation before mounting an auto-fit viewer. Returning a window // while this document was loading must not let it take sizing control again. _windowReady = ready && !_ended; _ended |= !ready; StateHasChanged(); } } catch (JSDisconnectedException) { // A new document will independently validate its generation. } catch (Exception ex) { Logger.LogWarning(ex, "Failed to coordinate the detached terminal window."); if (!_disposed) { _windowTrackingFailed = true; StateHasChanged(); } } } /// <summary>Stops rendering a detached viewer after its generation was explicitly returned or replaced.</summary> /// <param name="id">The browser registration to revoke.</param> /// <returns>A task that completes after the viewer is removed.</returns> [JSInvokable] public Task OnDetachedTerminalWindowRevokedAsync(string id) => InvokeAsync(() => { if (!_disposed && _windowRegistrationId == id) { _ended = true; _windowReady = false; StateHasChanged(); } }); /// <summary>Reports a browser coordination failure without treating it as a successfully recovered window.</summary> /// <param name="id">The affected browser registration.</param> /// <returns>A task that completes after the failure is displayed.</returns> [JSInvokable] public Task OnDetachedTerminalWindowTrackingFailedAsync(string id) => InvokeAsync(() => { if (!_disposed && _windowRegistrationId == id) { _windowTrackingFailed = true; _windowReady = false; StateHasChanged(); } }); private async Task StopWindowTrackingAsync(bool release) { if (_windowRegistrationTask is { } registration) { await registration; } if (_windowModule is { } module && _windowRegistrationId is { } id) { _windowRegistrationId = null; try { await module.InvokeVoidAsync(release ? "releaseDetachedTerminalWindow" : "unregisterDetachedTerminalWindow", id); } catch (JSDisconnectedException) { // Disposal on document reload must not revoke the durable detachment. } catch (Exception ex) { Logger.LogWarning(ex, "Failed to release detached terminal window tracking."); } } } private async Task WatchTerminalsAsync(string terminalId, CancellationToken cancellationToken) { try { cancellationToken.ThrowIfCancellationRequested(); await foreach (var update in DashboardClient.SubscribeTerminalsAsync(cancellationToken).ConfigureAwait(false)) { await InvokeAsync(() => { // An update can already be queued on the renderer when the window is disposed. if (_disposed || cancellationToken.IsCancellationRequested) { return; } var changed = update.KindCase switch { WatchTerminalsUpdate.KindOneofCase.Snapshot => ApplySnapshot(terminalId, update.Snapshot), WatchTerminalsUpdate.KindOneofCase.Change => ApplyChange(terminalId, update.Change), _ => false }; if (changed) { StateHasChanged(); } }).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // The window is closing. } catch (Exception ex) { // Transport failures are retried by the client. Log unexpected failures without failing the circuit. Logger.LogWarning(ex, "Terminal window watch stream ended unexpectedly."); } } private bool ApplySnapshot(string terminalId, TerminalDescriptorList snapshot) { var descriptor = snapshot.Terminals.FirstOrDefault(t => t.TerminalId == terminalId); if (descriptor is null) { // Detached windows can outlive the terminal they were opened for, including across a dashboard restart. return MarkEnded(); } return SetTitle(descriptor.Title); } private bool ApplyChange(string terminalId, TerminalChangeNotification change) { if (change.Terminal.TerminalId != terminalId) { return false; } return change.ChangeType is TerminalChangeType.Removed ? MarkEnded() : SetTitle(change.Terminal.Title); } private bool SetTitle(string title) { if (string.IsNullOrEmpty(title) || _title == title) { return false; } _title = title; return true; } private bool MarkEnded() { if (_ended) { return false; } _ended = true; return true; } /// <inheritdoc /> public async ValueTask DisposeAsync() { if (_disposed) { return; } _disposed = true; _cts.Cancel(); await _watchTask.ConfigureAwait(false); await StopWindowTrackingAsync(release: false).ConfigureAwait(false); if (_windowModule is { } module) { await Utils.JSInteropHelpers.SafeDisposeAsync(module).ConfigureAwait(false); } _windowReference?.Dispose(); _cts.Dispose(); } }