Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9b4cbdf
Add Console/Terminal view toggle to ConsoleLogs page
mitchdenny Jun 30, 2026
119d90d
Rename Console option to 'Console logs' in view picker
mitchdenny Jun 30, 2026
f249396
Fix terminal view duplication and auto-switch on resource restart
mitchdenny Jun 30, 2026
1434be4
Flip back to Console when a terminal resource is manually stopped
mitchdenny Jun 30, 2026
20a86e5
Suppress auto-switch back to Terminal while resource is stopped
mitchdenny Jun 30, 2026
c31f24c
Fix text selection offset in secondary TerminalView mode
mitchdenny Jul 1, 2026
647072f
Refine terminal font after first-render calibration
mitchdenny Jul 1, 2026
f2fec8f
Merge remote-tracking branch 'origin/main' into mitchdenny-console-te…
mitchdenny Jul 1, 2026
03ed1a2
Address PR feedback: dispatcher-safe StateHasChanged, nest ConsoleLog…
mitchdenny Jul 1, 2026
c81effd
Document manual-Stop auto-switch trigger in with-terminal spec
mitchdenny Jul 2, 2026
7a5cbef
Address Adam's feedback: dispatcher-safe stopped edge, exit reattach …
mitchdenny Jul 2, 2026
c28ca82
Simplify terminal toolbar: labelled font/dimensions dropdowns, right-…
mitchdenny Jul 2, 2026
445845c
Refit terminal after font-size change so cols×rows adjust
mitchdenny Jul 2, 2026
1857909
Revert to font +/- buttons and drop broken RAF re-fit
mitchdenny Jul 2, 2026
e94af21
Shorten Fit label and inline live dims into dimensions dropdown
mitchdenny Jul 2, 2026
e88e9a3
Collapse terminal toolbar controls into the settings menu
mitchdenny Jul 2, 2026
0f9fdb6
Show Fit's predicted dimensions in Terminal dimensions menu
mitchdenny Jul 2, 2026
d31ec0d
Improve icons in terminal options menu
mitchdenny Jul 2, 2026
0887485
Skip terminal layout when container is display:none
mitchdenny Jul 2, 2026
a17f5c9
Address PR feedback: remove dead resource, fix stale comments
mitchdenny Jul 2, 2026
a3e3775
Rebuild options menu after subscription switch
mitchdenny Jul 2, 2026
2b16a2f
Merge remote-tracking branch 'origin/main' into mitchdenny-console-te…
mitchdenny Jul 2, 2026
252eb33
Keep 'Console logs' as page title and header in Terminal view
mitchdenny Jul 2, 2026
ebc76a6
Address race-condition feedback from PR review
mitchdenny Jul 3, 2026
e410159
Fix dashboard terminal view races
adamint Jul 3, 2026
249bebb
Show checkmark on active Console/Terminal view menu item
mitchdenny Jul 3, 2026
713d37a
Remove Console/Terminal auto-switching
mitchdenny Jul 3, 2026
c4b0040
Remove dead terminal exit callback plumbing
mitchdenny Jul 3, 2026
2bf7a52
Drop checkmark indicators from menu items and pad terminal body
mitchdenny Jul 3, 2026
b1ecaec
Show live cols x rows in terminal titlebar
mitchdenny Jul 3, 2026
8f8774a
Fix Fit dimensions preview drift after resize
mitchdenny Jul 3, 2026
78b1146
Address Copilot review feedback
mitchdenny Jul 3, 2026
b866005
Fix Fit menu preview using fitAddon.proposeDimensions()
mitchdenny Jul 3, 2026
3f8350f
Fit preview: use fitFontPx ratio math, not proposeDimensions
mitchdenny Jul 3, 2026
37be98f
Remove dead TerminalToolbarCurrentGrid resource
mitchdenny Jul 3, 2026
e3ab44f
Reset currentFontPx to fitFontPx when entering Fit mode
mitchdenny Jul 3, 2026
25e7551
TEMP: diagnostic logging for Fit selection
mitchdenny Jul 3, 2026
2651c68
Force xterm cell re-measure on every font-size change
mitchdenny Jul 3, 2026
7372e0a
TEMP: verbose diag around setSizeMode + safeFit
mitchdenny Jul 3, 2026
f6b3e28
Address Copilot review feedback
mitchdenny Jul 3, 2026
22d8994
Reject stale calibrateRatios samples that swing >40%
mitchdenny Jul 3, 2026
9887b3a
Guard HandleViewChangedAsync against non-terminal resources
mitchdenny Jul 3, 2026
031a52f
Make Fit preview reflect real fit() output, not ratio math
mitchdenny Jul 3, 2026
266e0e6
Remove unused TerminalToolbarFontSize resource + fix line ref
mitchdenny Jul 3, 2026
59f9cb9
Remove Fit preview dimensions from terminal size menu
mitchdenny Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/specs/with-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,37 @@ to connect to an arbitrary local socket — it can only ask for
`(resource, replica)` pairs that are present in the resource snapshot
stream.

### Console / Terminal view toggle

For a terminal-enabled resource the dashboard `ConsoleLogs` page mounts
**both** `LogViewer` (the resource's standard log stream) and
`TerminalView` (the interactive xterm.js terminal) at the same time and
flips between them via a small "View" `FluentSelect` in the toolbar:
Comment thread
mitchdenny marked this conversation as resolved.
Outdated

- The page defaults to **Console** on resource selection so any pre-PTY
hosting messages — `WaitFor` notifications, startup failures, image
pull progress — are visible immediately.
- The page **auto-switches to Terminal** the first time the JS terminal
reports a non-`connecting` toolbar status (PTY attached). Reconnects do
not re-trigger the auto-switch.
- The page **auto-switches back to Console** when the JS terminal raises
`client.onExit` (PTY exited), so the user sees the final log lines
including the hosting exit message.
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
- Once the user manually picks a view from the dropdown the page
**latches** their choice for the rest of that resource's session and
ignores subsequent auto-switch triggers. The latch resets when a
different resource is selected.
- Both views stay mounted across flips (visibility is toggled with
`display:none` on a wrapper `<div>`); the log subscription and the
xterm/HMP1 consumer session are kept alive so neither view loses
scrollback or has to re-handshake on toggle. After a `display:none →
visible` transition the page calls `refreshLayout` on the JS terminal
to guarantee xterm rebinds to the new available space.

The console log stream is now subscribed to for terminal-enabled
resources too (previously it was suppressed), which is what makes the
Console view non-empty for a `WithTerminal()` resource.

## CLI

`aspire terminal <resource> [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`)
Expand Down
91 changes: 91 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
private int _terminalId;
private string? _connectedResourceName;
private int _connectedReplicaIndex = -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
Expand All @@ -45,6 +58,16 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
[Parameter]
public EventCallback<TerminalToolbarState> OnToolbarStateChanged { get; set; }

/// <summary>
/// Raised when the JS side reports that the workload (PTY) has exited.
/// The host page subscribes so it can auto-switch the view back to the
/// resource's console logs — the workload has stopped producing
/// terminal bytes and the final exit code / hosting messages live in
/// the console log stream.
/// </summary>
[Parameter]
public EventCallback<TerminalExitInfo> OnExited { get; set; }

[Inject]
public required IJSRuntime JS { get; init; }

Expand All @@ -60,12 +83,25 @@ protected override async Task OnAfterRenderAsync(bool firstRender)

if (firstRender)
{
_initStarted = true;
await InitializeTerminalAsync();
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
_connectedResourceName = ResourceName;
_connectedReplicaIndex = ReplicaIndex;
return;
Comment thread
mitchdenny marked this conversation as resolved.
}

// 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
Expand Down Expand Up @@ -157,6 +193,26 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex)
}
}

/// <summary>
/// Invoked by the JS terminal when the workload (PTY) exits. The JS side
/// also writes a "[workload exited with code N]" line into the xterm
/// buffer; this callback exists so the host page can react beyond the
/// in-terminal message (e.g. flip the visible view back to console logs).
/// </summary>
[JSInvokable]
public Task OnTerminalExited(int terminalId, int exitCode)
{
// Drop stale notifications that arrive after this view was rebound to
// a different resource/replica — the previous JS terminal id is no
// longer relevant to the currently displayed resource.
if (_terminalId != 0 && terminalId != _terminalId)
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
{
return Task.CompletedTask;
}

return OnExited.InvokeAsync(new TerminalExitInfo { TerminalId = terminalId, ExitCode = exitCode });
}

/// <summary>
/// Invoked by the JS terminal whenever its role/size/font state changes.
/// Forwards the snapshot to the host page via <see cref="OnToolbarStateChanged"/>.
Expand Down Expand Up @@ -279,6 +335,29 @@ public async Task RefreshToolbarStateAsync()
}
}

/// <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);
Expand Down Expand Up @@ -364,3 +443,15 @@ public sealed record TerminalToolbarState
/// host page's size dropdown).
/// </summary>
public sealed record TerminalSizePreset(string Value, string Label, int Cols, int Rows);

/// <summary>
/// Payload pushed up from the JS terminal when the workload (PTY) exits.
/// </summary>
public sealed record TerminalExitInfo
{
/// <summary>The JS-side terminal id that emitted the exit notification.</summary>
public int TerminalId { get; init; }

/// <summary>The workload's exit code, or <c>-1</c> when the JS side did not receive one.</summary>
public int ExitCode { get; init; }
}
40 changes: 40 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,20 @@ function buildChrome(state) {
const blazorElement = state.element;
if (!blazorElement) return;

// Defense in depth: never leave a previous terminal's chrome attached to
// the Blazor container element. The .NET-side OnAfterRenderAsync guard
// is the primary protection against re-entrant initialization, but if
// anything ever calls initTerminal twice against the same element
// (resource stop+restart bursts, lifecycle bugs, future hot-reload, …)
// appending another host on top of an existing one leaves multiple
// stacked xterm instances all wired to the same WebSocket — input
// echoes everywhere and the terminals can render at different sizes.
// Clearing the element first means worst-case we drop the previous
// (now-orphaned) chrome instead of duplicating it.
while (blazorElement.firstChild) {
blazorElement.removeChild(blazorElement.firstChild);
}

// The Blazor element already has inline width/height: 100%. Wrap
// it with our own host so we can apply our flex column layout
// without disturbing whatever else the parent has set on it.
Expand Down Expand Up @@ -1079,6 +1093,19 @@ function connectClient(state, wsUrl) {
try {
state.term?.write(`\r\n[workload exited with code ${code}]\r\n`);
} catch { /* ignore */ }
// Notify the .NET host that the workload (PTY) has exited so the
// page can flip back to the Console logs view. We deliberately use
// a separate JSInvokable rather than overloading the toolbar
// snapshot because the toolbar status (connecting/primary/viewer/
// no-primary) describes the consumer-side WebSocket and primary
// ownership — it doesn't directly signal producer exit.
if (state.dotNetRef) {
try {
state.dotNetRef.invokeMethodAsync('OnTerminalExited', state.id, code ?? -1);
} catch (e) {
dbg(state, 'client.onExit: dotNet invoke failed', { error: e?.message });
}
}
};

client.onClose = (ev) => {
Expand Down Expand Up @@ -1264,3 +1291,16 @@ export function refreshToolbarState(id) {
state._lastToolbarJson = null;
flushToolbarState(state);
}

// Triggers a layout recompute on demand. Called by the .NET host after the
// terminal element becomes visible again following a Console/Terminal view
// flip — the wrapper goes from display:none to visible, which may or may
// not trigger ResizeObserver depending on the browser's box-tree timing.
// Forcing applyRoleAwareLayout here guarantees xterm rebinds to the new
// available space immediately rather than waiting for the next external
// resize event.
export function refreshLayout(id) {
const state = terminals.get(id);
if (!state) return;
applyRoleAwareLayout(state);
}
76 changes: 57 additions & 19 deletions src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<PageTitle>
<ApplicationName
AdditionalText="@PageViewModel.SelectedResource.Id?.ReplicaSetName"
ResourceName="@(_selectedResourceHasTerminal ? nameof(Dashboard.Resources.ConsoleLogs.TerminalPageTitle) : nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsPageTitle))"
ResourceName="@(_activeView == ConsoleLogsView.Terminal ? nameof(Dashboard.Resources.ConsoleLogs.TerminalPageTitle) : nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsPageTitle))"
Loc="@Loc"/>
</PageTitle>

Expand All @@ -18,7 +18,7 @@
MainContentStyle="margin-top: 10px;"
MobileToolbarButtonText="@Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsSelectResourceToolbar)]">
<PageTitleSection>
<h1 class="page-header">@Loc[_selectedResourceHasTerminal ? nameof(Dashboard.Resources.ConsoleLogs.TerminalHeader) : nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsHeader)]</h1>
<h1 class="page-header">@Loc[_activeView == ConsoleLogsView.Terminal ? nameof(Dashboard.Resources.ConsoleLogs.TerminalHeader) : nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsHeader)]</h1>
</PageTitleSection>
<ToolbarSection>
<ResourceSelect Resources="_resources"
Expand All @@ -27,7 +27,24 @@
@bind-SelectedResource:after="HandleSelectedOptionChangedAsync"
LabelClass="toolbar-left" />

@if (!_selectedResourceHasTerminal)
@if (_selectedResourceHasTerminal)
{
// View toggle: only meaningful for terminal-enabled resources.
// For non-terminal resources Console is the only thing to look
// at, so the dropdown stays hidden — keeping the toolbar
// identical to today for the common case.
<FluentSelect TOption="string"
Items="@s_viewSelectOptions"
OptionValue="@(o => o)"
OptionText="@(o => string.Equals(o, nameof(ConsoleLogsView.Terminal), StringComparison.Ordinal) ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)].Value : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewConsoleOption)].Value)"
Value="@_activeView.ToString()"
ValueChanged="HandleViewChangedAsync"
Width="120px"
Style="min-width: auto; margin-left: 10px;"
AriaLabel="@Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewSelectorLabel)]" />
}

@if (_activeView == ConsoleLogsView.Console)
{
<SignalsActionsDisplay HandleClearSignal="@ClearConsoleLogs"
IsPaused="@PauseManager.ConsoleLogsPaused"
Expand Down Expand Up @@ -63,7 +80,7 @@
}
}

@if (_selectedResourceHasTerminal && _terminalToolbarState is { } terminalState)
@if (_activeView == ConsoleLogsView.Terminal && _terminalToolbarState is { } terminalState)
{
<FluentDivider Role="DividerRole.Presentation" Orientation="Orientation.Vertical" />

Expand Down Expand Up @@ -111,7 +128,7 @@
@if (ViewportInformation.IsDesktop)
{
// This takes up too much horizontal space on mobile, so show on a new line on mobile
@if (!_selectedResourceHasTerminal)
@if (_activeView == ConsoleLogsView.Console)
{
<FluentLabel Typo="Typography.Body" aria-live="polite" aria-label="@Loc[nameof(Dashboard.Resources.ConsoleLogs.LogStatusLabel)]" slot="end">@PageViewModel.Status</FluentLabel>

Expand All @@ -123,7 +140,7 @@
slot="end"/>
}
}
else if (!_selectedResourceHasTerminal)
else if (_activeView == ConsoleLogsView.Console)
{
<AspireMenuButton
Icon="@(new Icons.Regular.Size20.Options())"
Expand All @@ -135,7 +152,7 @@
</ToolbarSection>

<MobilePageTitleToolbarSection>
@if (!_selectedResourceHasTerminal)
@if (_activeView == ConsoleLogsView.Console)
{
<FluentLabel Typo="Typography.Body" aria-live="polite" aria-label="@Loc[nameof(Dashboard.Resources.ConsoleLogs.LogStatusLabel)]">
@if (PageViewModel.SelectedResource?.Id is not null)
Expand All @@ -151,24 +168,45 @@
</MobilePageTitleToolbarSection>

<MainSection>
@* For terminal-enabled resources we keep BOTH views mounted and
flip visibility via CSS. Unmounting TerminalView on every flip
would tear down xterm.js, the WebSocket, and the PTY consumer
session — destroying scrollback and forcing an HMP1
StateSync round-trip on every toggle. Unmounting LogViewer
would drop the rendered log buffer for the same reason.
`display:none` keeps the DOM, JS state, and Blazor component
state alive while only one view is visible at a time. *@
@if (_selectedResourceHasTerminal && _terminalResourceName is not null)
{
<TerminalView
@ref="_terminalViewRef"
ResourceName="@_terminalResourceName"
ReplicaIndex="@_terminalReplicaIndex"
OnToolbarStateChanged="OnTerminalToolbarStateChangedAsync" />
<div style="@(_activeView == ConsoleLogsView.Console ? "display: contents;" : "display: none;")">
Comment thread
mitchdenny marked this conversation as resolved.
<LogViewer
@ref="_logViewerRef"
LogEntries="@_logEntries"
ShowTimestamp="@_showTimestamp"
IsTimestampUtc="@_isTimestampUtc"
NoWrapLogs="@_noWrapLogs"
ShowNoLogsMessage="@_showNoLogsMessage"
ShowResourcePrefix="@_isSubscribedToAll"/>
</div>
<div style="@(_activeView == ConsoleLogsView.Terminal ? "display: contents;" : "display: none;")">
<TerminalView
@ref="_terminalViewRef"
ResourceName="@_terminalResourceName"
ReplicaIndex="@_terminalReplicaIndex"
OnToolbarStateChanged="OnTerminalToolbarStateChangedAsync"
OnExited="OnTerminalExitedAsync" />
</div>
}
else
{
<LogViewer
@ref="_logViewerRef"
LogEntries="@_logEntries"
ShowTimestamp="@_showTimestamp"
IsTimestampUtc="@_isTimestampUtc"
NoWrapLogs="@_noWrapLogs"
ShowNoLogsMessage="@_showNoLogsMessage"
ShowResourcePrefix="@_isSubscribedToAll"/>
@ref="_logViewerRef"
LogEntries="@_logEntries"
ShowTimestamp="@_showTimestamp"
IsTimestampUtc="@_isTimestampUtc"
NoWrapLogs="@_noWrapLogs"
ShowNoLogsMessage="@_showNoLogsMessage"
ShowResourcePrefix="@_isSubscribedToAll"/>
}
</MainSection>
</AspirePageContentLayout>
Expand Down
Loading
Loading