// 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.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace Aspire.Dashboard.Components.Controls;
/// <summary>
/// Renders an interactive terminal using xterm.js, connected to the resource's
/// per-replica terminal session via a WebSocket bridge to the AppHost-owned
/// terminal host (HMP v1 over Unix domain socket).
/// </summary>
public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
{
private ElementReference _terminalElement;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<TerminalView>? _selfRef;
private int _terminalId;
private string? _connectedResourceName;
private int _connectedReplicaIndex = -1;
// Highest reconnect generation we've observed from JS via a toolbar
// snapshot. The JS side bumps `state.reconnect.generation` on every
// initTerminal / reconnectTerminal / auto-reconnect. `reconnectTerminal`
// keeps the same terminal id, so terminal id alone can't tell us whether
// a late-arriving `onExit` or `OnTerminalStateChanged` callback belongs
// to the currently bound connection or to a superseded one. Any callback
// whose generation is below _connectedGeneration is stale and dropped.
private int _connectedGeneration = -1;
// Guards against concurrent or re-entrant initialization. OnAfterRenderAsync
// can fire again while the first InitializeTerminalAsync await is still in
// flight (Blazor does not serialize OnAfterRenderAsync calls when re-renders
// happen during awaits). Without this latch, the non-firstRender branch
// below would see _connectedResourceName == null, mistake that for "rebind
// needed", call ReconnectAsync, and — because _terminalId is also still 0
// — fall through to InitializeTerminalAsync a second time. Each
// initTerminal call appends a brand-new xterm host element to the same
// Blazor container, leaving multiple stacked terminals in the DOM that
// mirror the same input/output stream. This pattern is easy to trigger
// on a resource stop+restart where the dashboard fires a burst of
// resource-snapshot-driven re-renders right after the page mounts.
private bool _initStarted;
/// <summary>
/// Gets or sets the user-facing display name of the resource that owns the
/// terminal session (e.g. <c>myapp</c>, not the per-replica DCP suffix).
/// </summary>
[Parameter]
public string? ResourceName { get; set; }
/// <summary>
/// Gets or sets the stable 0-based replica index for the terminal session.
/// Defaults to <c>0</c> for single-replica resources.
/// </summary>
[Parameter]
public int ReplicaIndex { get; set; }
/// <summary>
/// Raised when the JS side pushes a fresh toolbar state snapshot (role,
/// dims, font size, etc.). The host page subscribes so the chrome that
/// used to live inside the terminal frame — status badge, "Take control"
/// button, font controls, size dropdown, dims readout — can be rendered
/// in the page's existing toolbar instead.
/// </summary>
[Parameter]
public EventCallback<TerminalToolbarState> OnToolbarStateChanged { get; set; }
[Inject]
public required IJSRuntime JS { get; init; }
[Inject]
public required NavigationManager NavigationManager { get; init; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (string.IsNullOrEmpty(ResourceName))
{
return;
}
if (firstRender)
{
_initStarted = true;
// Snapshot the resource/replica values BEFORE the JS init await:
// parameter push from the parent can change ResourceName/
// ReplicaIndex while initTerminal is in flight. Recording the
// *post-await* field values would falsely mark the terminal as
// connected to the new resource, so the rebind branch below
// would never fire and the JS terminal would keep streaming
// the previous resource.
var initResource = ResourceName;
var initReplica = ReplicaIndex;
await InitializeTerminalAsync(initResource!, initReplica);
// Only record the connected resource/replica when JS init actually
// produced a terminal. If _terminalId is still 0, InitializeTerminalAsync
// caught an exception; leaving _connectedResourceName null lets the
// rebind branch below (and future renders) notice and retry rather
// than silently masking the failure.
if (_terminalId != 0)
{
_connectedResourceName = initResource;
_connectedReplicaIndex = initReplica;
}
if (!string.Equals(ResourceName, _connectedResourceName, StringComparison.Ordinal) ||
ReplicaIndex != _connectedReplicaIndex)
{
var newResource = ResourceName;
var newReplica = ReplicaIndex;
try
{
await ReconnectAsync(newResource, newReplica);
}
catch (JSDisconnectedException)
{
return;
}
catch (Exception)
{
return;
}
_connectedResourceName = newResource;
_connectedReplicaIndex = newReplica;
}
return;
}
// If a re-render fires while the very first initTerminal call is still
// in flight, do nothing here. Once that call completes the firstRender
// path will set _connectedResourceName / _connectedReplicaIndex and
// any future rebind needed will be caught on the next render after
// that. Without this guard the rebind branch below would re-enter
// initialization and stack a second xterm onto the same container —
// see the comment on _initStarted.
if (_initStarted && _terminalId == 0)
{
return;
}
// The same TerminalView instance is reused across resource/replica
// switches in the parent (e.g. ConsoleLogs page selects a different
// terminal-enabled resource). Detect that here and rebind the
// underlying WebSocket; xterm.js is preserved and just gets cleared
// and refilled by the new connection's StateSync replay.
//
// ALL exceptions are swallowed at this layer because OnAfterRenderAsync
// is a Blazor lifecycle method: an unhandled exception here can fail
// the SignalR circuit and tear down the entire dashboard tab. Failing
// to switch terminals is a localized, recoverable issue (the JS side
// will keep retrying or the user can reload); a circuit failure is not.
if (!string.Equals(ResourceName, _connectedResourceName, StringComparison.Ordinal) ||
ReplicaIndex != _connectedReplicaIndex)
{
var newResource = ResourceName;
var newReplica = ReplicaIndex;
try
{
await ReconnectAsync(newResource, newReplica);
}
catch (JSDisconnectedException)
{
// Component is being disposed; don't bother updating tracked state.
return;
}
catch (Exception)
{
// Defensive: any other JS-side error must not bubble out of
// a Blazor lifecycle method. The reconnect loop on the JS
// side keeps retrying so a transient hiccup heals itself.
return;
}
_connectedResourceName = newResource;
_connectedReplicaIndex = newReplica;
}
}
private async Task InitializeTerminalAsync(string resourceName, int replicaIndex)
{
try
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "/Components/Controls/TerminalView.razor.js");
_selfRef ??= DotNetObjectReference.Create(this);
_connectedGeneration = -1;
_terminalId = await _jsModule.InvokeAsync<int>(
"initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef);
}
catch (JSDisconnectedException)
{
// Component disposed during initialization. Clear _initStarted so
// that if a later render *does* fire (e.g. reconnection scenarios
// that re-mount the JS module), OnAfterRenderAsync's
// `_initStarted && _terminalId == 0` short-circuit doesn't
// permanently wedge us with no terminal.
_initStarted = false;
}
catch (Exception)
{
// Defensive: any other JS-side error (e.g. JSException while
// importing the module or during initTerminal) must not bubble
// out of a Blazor lifecycle method — that can tear down the
// SignalR circuit and take the whole dashboard tab with it.
// Clear _initStarted so a subsequent render can retry, and leave
// _terminalId == 0 so the firstRender path in OnAfterRenderAsync
// does not record a connected resource for a terminal that was
// never created.
_initStarted = false;
}
}
/// <summary>
/// Reconnects the terminal to a different resource/replica. When both
/// arguments match the current values this is a no-op.
/// </summary>
public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex)
{
if (_jsModule is null || _terminalId == 0)
{
ResourceName = newResourceName;
ReplicaIndex = newReplicaIndex;
if (!string.IsNullOrEmpty(newResourceName))
{
await InitializeTerminalAsync(newResourceName, newReplicaIndex);
}
return;
}
try
{
if (string.IsNullOrEmpty(newResourceName))
{
await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId);
_terminalId = 0;
_connectedGeneration = -1;
return;
}
ResourceName = newResourceName;
ReplicaIndex = newReplicaIndex;
var generation = await _jsModule.InvokeAsync<int>(
"reconnectTerminal",
_terminalId,
BuildWebSocketUrl(newResourceName, newReplicaIndex));
if (generation > 0)
{
_connectedGeneration = generation;
}
}
catch (JSDisconnectedException)
{
// Component disposed mid-call; nothing to do.
}
}
/// <summary>
/// Invoked by the JS terminal whenever its role/size/font state changes.
/// Forwards the snapshot to the host page via <see cref="OnToolbarStateChanged"/>.
/// JS remains the source of truth for terminal state — the toolbar
/// renders whatever the most recent snapshot says.
/// </summary>
[JSInvokable]
public Task OnTerminalStateChanged(TerminalToolbarState state)
{
if (IsStaleTerminalCallback(state.TerminalId, state.Generation))
{
return Task.CompletedTask;
}
return OnToolbarStateChanged.InvokeAsync(state);
}
private bool IsStaleTerminalCallback(int terminalId, int generation)
{
// Drop stale callbacks that arrive after this view was rebound. The
// terminal id changes when initTerminal allocates a new xterm host;
// explicit reconnect keeps the id but bumps the JS-side generation.
if (_terminalId != 0 && terminalId != _terminalId)
{
return true;
}
if (generation < _connectedGeneration)
{
return true;
}
if (generation > _connectedGeneration)
{
_connectedGeneration = generation;
}
return false;
}
/// <summary>
/// Sets the terminal font size and switches sizing back to "Auto" (font-
/// driven) mode. Out-of-range values are clamped by the JS side.
/// </summary>
public async Task SetFontSizeAsync(int fontPx)
{
if (_jsModule is null || _terminalId == 0)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("setFontSizeFromHost", _terminalId, fontPx);
}
catch (JSDisconnectedException)
{
}
}
/// <summary>
/// Sets the sizing mode by preset key (<c>auto</c> or one of the
/// <see cref="TerminalToolbarState.SizeKey"/> values). Unknown keys are
/// ignored by the JS side.
/// </summary>
public async Task SetSizeModeAsync(string sizeKey)
{
if (_jsModule is null || _terminalId == 0)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("setSizeModeFromHost", _terminalId, sizeKey);
}
catch (JSDisconnectedException)
{
}
}
/// <summary>
/// Fetches the set of size presets exposed by the JS terminal so the
/// host page's size dropdown stays in sync with the values JS knows
/// how to handle.
/// </summary>
public async Task<IReadOnlyList<TerminalSizePreset>> GetSizePresetsAsync()
{
if (_jsModule is null)
{
return Array.Empty<TerminalSizePreset>();
}
try
{
return await _jsModule.InvokeAsync<TerminalSizePreset[]>("getSizePresets");
}
catch (JSDisconnectedException)
{
return Array.Empty<TerminalSizePreset>();
}
}
/// <summary>
/// Asks the JS terminal to re-push its current toolbar snapshot,
/// bypassing the change-detection cache. Called by the host page when
/// it has lost its cached snapshot (e.g. across a layout transition)
/// but the JS terminal is still live and the cached "last pushed JSON"
/// would otherwise suppress a fresh push.
/// </summary>
public async Task RefreshToolbarStateAsync()
{
if (_jsModule is null || _terminalId == 0)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("refreshToolbarState", _terminalId);
}
catch (JSDisconnectedException)
{
}
}
/// <summary>
/// Asks the JS terminal to recompute its layout. Called by the host
/// page when the terminal element transitions from hidden back to
/// visible (e.g. the user flips the page-level View dropdown from
/// Console back to Terminal) — display:none → visible does not always
/// trigger ResizeObserver, so forcing a relayout here guarantees the
/// terminal fills the available space immediately.
/// </summary>
public async Task RefreshLayoutAsync()
{
if (_jsModule is null || _terminalId == 0)
{
return;
}
try
{
await _jsModule.InvokeVoidAsync("refreshLayout", _terminalId);
}
catch (JSDisconnectedException)
{
}
}
private string BuildWebSocketUrl(string resource, int replica)
{
var baseUri = new Uri(NavigationManager.BaseUri);
var wsScheme = baseUri.Scheme == "https" ? "wss" : "ws";
return $"{wsScheme}://{baseUri.Authority}/api/terminal?resource={Uri.EscapeDataString(resource)}&replica={replica}";
}
public async ValueTask DisposeAsync()
{
if (_jsModule is not null && _terminalId != 0)
{
try
{
await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId);
}
catch (JSDisconnectedException)
{
// Expected during shutdown
}
}
if (_jsModule is not null)
{
await JSInteropHelpers.SafeDisposeAsync(_jsModule);
}
_selfRef?.Dispose();
_selfRef = null;
}
}
/// <summary>
/// Snapshot of the JS terminal's current role, sizing, and dims, pushed up
/// to the host page so the toolbar can render the right state.
/// </summary>
public sealed record TerminalToolbarState
{
/// <summary>Unique JS-side terminal id (allocated by <c>initTerminal</c>).</summary>
public int TerminalId { get; init; }
/// <summary>Reconnect generation; bumped on every (re)connect.</summary>
public int Generation { get; init; }
/// <summary>
/// One of <c>connecting</c>, <c>primary</c>, <c>viewer</c>, <c>no-primary</c>.
/// </summary>
public string Status { get; init; } = "connecting";
/// <summary>True once the HMP1 client has a peer id assigned.</summary>
public bool Connected { get; init; }
/// <summary>True when this client owns primary input on the producer.</summary>
public bool IsPrimary { get; init; }
/// <summary>True when "Take control" is meaningful to surface.</summary>
public bool CanTakeControl { get; init; }
/// <summary>Current sizing mode (<c>font</c> or <c>fixed</c>).</summary>
public string SizeMode { get; init; } = "font";
/// <summary>
/// Dropdown key — <c>auto</c> for font-driven sizing or <c>{cols}x{rows}</c>
/// for a preset.
/// </summary>
public string SizeKey { get; init; } = "auto";
/// <summary>Current xterm font size in CSS pixels.</summary>
public int FontPx { get; init; }
/// <summary>Whether font ± buttons should be enabled (Auto mode + primary).</summary>
public bool FontControlsEnabled { get; init; }
/// <summary>Whether the size dropdown should be enabled (primary).</summary>
public bool SizeSelectEnabled { get; init; }
/// <summary>Current xterm grid width.</summary>
public int Cols { get; init; }
/// <summary>Current xterm grid height.</summary>
public int Rows { get; init; }
}
/// <summary>
/// A named size preset surfaced by the JS terminal (used to populate the
/// host page's size dropdown).
/// </summary>
public sealed record TerminalSizePreset(string Value, string Label, int Cols, int Rows);