diff --git a/docs/specs/with-terminal.md b/docs/specs/with-terminal.md index 4f10f8b8b18..8ea33c9db5c 100644 --- a/docs/specs/with-terminal.md +++ b/docs/specs/with-terminal.md @@ -116,6 +116,32 @@ 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 pair of **Console logs** / **Terminal** items +rendered inside the toolbar's options (⋯) `AspireMenuButton`: + +- 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 view is purely user-controlled: the page never auto-switches + between Console and Terminal. The user picks the view from the ⋯ + menu and the page stays on that view until they pick the other one, + or a different resource is selected (which resets to Console). +- Both views stay mounted across flips (visibility is toggled with + `display:none` on a wrapper `
`); 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 [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`) diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 3abf0122522..5bfaa2de180 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -20,6 +20,27 @@ public sealed partial class TerminalView : ComponentBase, IAsyncDisposable 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; /// /// Gets or sets the user-facing display name of the resource that owns the @@ -60,9 +81,61 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { - await InitializeTerminalAsync(); - _connectedResourceName = ResourceName; - _connectedReplicaIndex = ReplicaIndex; + _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; } @@ -103,7 +176,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } - private async Task InitializeTerminalAsync() + private async Task InitializeTerminalAsync(string resourceName, int replicaIndex) { try { @@ -112,12 +185,30 @@ private async Task InitializeTerminalAsync() _selfRef ??= DotNetObjectReference.Create(this); + _connectedGeneration = -1; _terminalId = await _jsModule.InvokeAsync( - "initTerminal", _terminalElement, BuildWebSocketUrl(ResourceName!, ReplicaIndex), _selfRef); + "initTerminal", _terminalElement, BuildWebSocketUrl(resourceName, replicaIndex), _selfRef); } catch (JSDisconnectedException) { - // Component disposed during initialization + // 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; } } @@ -133,7 +224,7 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) ReplicaIndex = newReplicaIndex; if (!string.IsNullOrEmpty(newResourceName)) { - await InitializeTerminalAsync(); + await InitializeTerminalAsync(newResourceName, newReplicaIndex); } return; } @@ -144,12 +235,20 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) { await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId); _terminalId = 0; + _connectedGeneration = -1; return; } ResourceName = newResourceName; ReplicaIndex = newReplicaIndex; - await _jsModule.InvokeVoidAsync("reconnectTerminal", _terminalId, BuildWebSocketUrl(newResourceName, newReplicaIndex)); + var generation = await _jsModule.InvokeAsync( + "reconnectTerminal", + _terminalId, + BuildWebSocketUrl(newResourceName, newReplicaIndex)); + if (generation > 0) + { + _connectedGeneration = generation; + } } catch (JSDisconnectedException) { @@ -166,10 +265,7 @@ public async Task ReconnectAsync(string? newResourceName, int newReplicaIndex) [JSInvokable] public Task OnTerminalStateChanged(TerminalToolbarState state) { - // Drop stale snapshots that arrive after this view was rebound to a - // different resource/replica (the JS side bumps `generation` on every - // (re)connect; the id changes when initTerminal allocates a new one). - if (_terminalId != 0 && state.TerminalId != _terminalId) + if (IsStaleTerminalCallback(state.TerminalId, state.Generation)) { return Task.CompletedTask; } @@ -177,24 +273,27 @@ public Task OnTerminalStateChanged(TerminalToolbarState state) return OnToolbarStateChanged.InvokeAsync(state); } - /// - /// Requests primary control of the producer session. No-op if the terminal - /// JS module hasn't initialized yet or if we're already primary; JS - /// performs the authoritative role checks. - /// - public async Task TakePrimaryAsync() + private bool IsStaleTerminalCallback(int terminalId, int generation) { - if (_jsModule is null || _terminalId == 0) + // 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; + return true; } - try + + if (generation < _connectedGeneration) { - await _jsModule.InvokeVoidAsync("takePrimaryFromHost", _terminalId); + return true; } - catch (JSDisconnectedException) + + if (generation > _connectedGeneration) { + _connectedGeneration = generation; } + + return false; } /// @@ -279,6 +378,29 @@ public async Task RefreshToolbarStateAsync() } } + /// + /// 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. + /// + 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); diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index f7df0dedf5c..6532d69c540 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -142,9 +142,14 @@ function cancelPendingReconnect(state) { // recompute font, cols×rows stay fixed (no broadcast). // // In secondary mode (someone else is primary), both control groups hide -// (.read-only) and we lock our xterm grid to the producer's cols×rows -// then CSS-scale .xterm to fit our viewport (letterboxing on whichever -// axis has spare room). +// (.read-only) and we lock our xterm grid to the producer's cols×rows, +// then pick the largest integer font size whose rendered grid fits the +// viewport (letterboxing on whichever axis has spare room). This mirrors +// primary fixed-mode; we deliberately avoid CSS transform: scale() here +// because xterm.js computes mouse-to-cell coordinates from +// getBoundingClientRect (which returns transformed dims) divided by its +// internally-measured cell width (which is untransformed), so any +// scale ≠ 1 offsets text selection by roughly the scale factor. const MIN_FONT_PX = 4; const MAX_FONT_PX = 72; const DEFAULT_FONT_PX = 13; @@ -311,11 +316,36 @@ function ensureTerminalStyles() { letter-spacing: 0.2px; } +/* + * Live cols × rows readout on the right side of the titlebar. Kept in + * sync from term.onResize so it always shows the grid the PTY sees. + */ +.aspire-terminal-host #terminal-dims { + flex: 0 0 auto; + margin-left: 12px; + padding-left: 12px; + border-left: 1px solid #30363d; + color: var(--aspire-term-fg-muted); + font-variant-numeric: tabular-nums; + letter-spacing: 0.2px; + white-space: nowrap; +} + .aspire-terminal-host #terminal-body { flex: 0 0 auto; position: relative; overflow: hidden; background: #0d1117; + /* + * Breathing room between the frame border and xterm's text so the + * output isn't flush against the edge (matches native terminal UX). + * Combined with box-sizing: border-box (inherited from the wildcard + * rule above), the padding shrinks the content area xterm renders + * into — the JS layout math in layoutTerminal / pinBodyToNatural adds + * TERMINAL_BODY_PADDING_PX * 2 back when pinning the body to the + * natural rendered dims so the frame keeps hugging the grid. + */ + padding: 6px; } .aspire-terminal-host .xterm:focus, @@ -387,6 +417,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. @@ -410,7 +454,10 @@ function buildChrome(state) { const titleText = document.createElement('span'); titleText.id = 'terminal-title'; titleText.textContent = 'terminal'; - titlebar.appendChild(titleText); + const dimsText = document.createElement('span'); + dimsText.id = 'terminal-dims'; + dimsText.textContent = ''; + titlebar.append(titleText, dimsText); const body = document.createElement('div'); body.id = 'terminal-body'; @@ -424,33 +471,70 @@ function buildChrome(state) { state.terminalFrame = frame; state.terminalTitlebar = titlebar; state.titleText = titleText; + state.dimsText = dimsText; state.terminalBody = body; } function safeFit(state) { + const term = state.term; + const before = term ? { cols: term.cols, rows: term.rows, fontSize: term.options?.fontSize } : null; try { state.fitAddon?.fit(); } catch { /* ignore — happens during teardown */ } + if (window.__aspireTerminalDebug) { + const after = term ? { cols: term.cols, rows: term.rows, fontSize: term.options?.fontSize } : null; + console.log('[TERMDIAG] safeFit', { + before, after, + currentFontPx: state.currentFontPx, + fitFontPx: state.fitFontPx, + sizeMode: state.sizeMode, + avail: getAvailableBodySpace(state), + isPrimary: !!state.client?.isPrimary, + producerDims: state.client ? { w: state.client.width, h: state.client.height } : null, + }); + } +} + +function updateDimsReadout(state) { + if (!state.dimsText || !state.term) return; + const cols = state.term.cols | 0; + const rows = state.term.rows | 0; + // xterm briefly reports 0x0 during teardown; suppress that instead of + // flashing a zero-sized readout at the user. + state.dimsText.textContent = cols > 0 && rows > 0 ? `${cols} × ${rows}` : ''; } const FRAME_BORDER_PX = 2; +// CSS `padding` on #terminal-body — kept in sync with the value in the +// injected stylesheet. box-sizing is border-box, so the content area +// xterm actually renders into is smaller than the outer body box by +// TERMINAL_BODY_PADDING_PX * 2 on each axis. getAvailableBodySpace +// returns the xterm-content area (padding subtracted) so callers can +// pass it straight to computeOptimalFont / fit(); fit-mode's body-pin +// and pinBodyToNatural add the padding back when they set the outer +// body dimensions. +const TERMINAL_BODY_PADDING_PX = 6; function getAvailableBodySpace(state) { const titlebarH = state.terminalTitlebar ? state.terminalTitlebar.offsetHeight : 0; const stageW = state.terminalContainer ? state.terminalContainer.clientWidth : 0; const stageH = state.terminalContainer ? state.terminalContainer.clientHeight : 0; + const outerW = Math.max(0, stageW - FRAME_BORDER_PX * 2); + const outerH = Math.max(0, stageH - titlebarH - FRAME_BORDER_PX * 2); return { - width: Math.max(0, stageW - FRAME_BORDER_PX * 2), - height: Math.max(0, stageH - titlebarH - FRAME_BORDER_PX * 2), + width: Math.max(0, outerW - TERMINAL_BODY_PADDING_PX * 2), + height: Math.max(0, outerH - TERMINAL_BODY_PADDING_PX * 2), }; } // Sizes the xterm display based on the current role and (in primary // mode) the current sizing mode. See docs/muxer-learnings.md §3. // -// - Secondary: lock the xterm grid to producer's cols×rows, then CSS -// transform: scale() .xterm so the rendered grid fills available -// space without distortion. Pin #terminal-body to the SCALED visible -// bounds so the frame card hugs the content (no empty layout space -// around the scaled grid). Letterboxing on whichever axis has spare -// room (preserves aspect). +// - Secondary: lock the xterm grid to producer's cols×rows and pick +// the largest integer font whose rendered grid fits the available +// stage. Pin #terminal-body to the natural rendered dims so the +// frame card hugs the grid (letterboxing appears in the stage on +// whichever axis has spare room). This is structurally the same as +// primary fixed-mode with fixedDims == producer dims, minus the +// resize broadcast — see the header comment for why we don't use +// CSS transform: scale() here. // // - Primary, font-driven: pin #terminal-body to available stage, run // fitAddon.fit() — grid grows/shrinks to fill at the user's chosen @@ -471,17 +555,29 @@ function applyRoleAwareLayout(state) { const body = root.parentElement; if (!body) return; + // Bail when the terminal container has been laid out to zero — most + // commonly because ConsoleLogs flipped this view to display:none while + // Console is active. Running the layout at zero would pin body.style + // width/height to 0px (fixed mode) or resize the xterm grid to 1x1 + // (fit mode), and neither necessarily gets reversed when the browser + // relayouts the container back to a real size. ConsoleLogs re-invokes + // refreshLayout on the way back to Terminal view, so we recover with + // a real size then. + const { width: probeW, height: probeH } = getAvailableBodySpace(state); + if (probeW <= 0 || probeH <= 0) return; + // Bump generation: any RAF callbacks queued by prior layout calls // become stale and will bail when they run. const generation = ++state.layoutGeneration; const haveProducerDims = !!state.client && state.client.width > 0 && state.client.height > 0; const isSecondary = !!state.client && !state.client.isPrimary && haveProducerDims; - const { width: availableW, height: availableH } = getAvailableBodySpace(state); + const availableW = probeW; + const availableH = probeH; if (!isSecondary) { - // Primary, no-primary, or pre-handshake: clear any secondary - // pinning on .xterm so it can flow naturally inside body. + // Primary, no-primary, or pre-handshake: clear any leftover + // .xterm inline styling so it flows naturally inside body. if (root.style.transform || root.style.width || root.style.height) { root.style.transform = ''; root.style.transformOrigin = ''; @@ -493,6 +589,7 @@ function applyRoleAwareLayout(state) { const optFont = computeOptimalFont(state, state.fixedDims.cols, state.fixedDims.rows, availableW, availableH); if (term.options.fontSize !== optFont) { term.options.fontSize = optFont; + forceFontRemeasure(term); } state.currentFontPx = optFont; if (term.cols !== state.fixedDims.cols || term.rows !== state.fixedDims.rows) { @@ -505,17 +602,23 @@ function applyRoleAwareLayout(state) { if (state.sizeMode !== 'fixed' || !state.fixedDims) return; if (state.fixedDims.cols !== expectedCols || state.fixedDims.rows !== expectedRows) return; pinBodyToNatural(state, root, body); + refineFontAfterCalibration(state, generation, expectedCols, expectedRows, + () => state.sizeMode === 'fixed' && state.fixedDims && + state.fixedDims.cols === expectedCols && state.fixedDims.rows === expectedRows); }); } else { - // Font-driven: pin body to available, fit() picks cols×rows. - const bodyW = `${availableW}px`; - const bodyH = `${availableH}px`; + // Font-driven: pin body to fill the pane (content + padding on + // each side, since body is border-box); fit() picks cols×rows + // for the padded content area. + const bodyW = `${availableW + TERMINAL_BODY_PADDING_PX * 2}px`; + const bodyH = `${availableH + TERMINAL_BODY_PADDING_PX * 2}px`; if (body.style.width !== bodyW || body.style.height !== bodyH) { body.style.width = bodyW; body.style.height = bodyH; } if (term.options.fontSize !== state.currentFontPx) { term.options.fontSize = state.currentFontPx; + forceFontRemeasure(term); } safeFit(state); } @@ -523,23 +626,68 @@ function applyRoleAwareLayout(state) { return; } - // Secondary lock-and-scale. - const needsResize = term.cols !== state.client.width || term.rows !== state.client.height; - if (needsResize) { - try { term.resize(state.client.width, state.client.height); } catch { /* ignore */ } + // Secondary: lock grid to producer dims, pick the largest integer + // font whose rendered grid fits, then hug the frame to the natural + // rendered size. No CSS transform — see the header comment for why. + // This is intentionally the same shape as primary fixed-mode above, + // minus the resize broadcast (secondary never drives the PTY). + const producerCols = state.client.width; + const producerRows = state.client.height; + const optFont = computeOptimalFont(state, producerCols, producerRows, availableW, availableH); + if (term.options.fontSize !== optFont) { + term.options.fontSize = optFont; + forceFontRemeasure(term); } - - // If we just resized, defer measurement to next frame so the - // renderer can write the new .xterm-screen dims first. - if (needsResize) { - requestAnimationFrame(() => { - if (generation !== state.layoutGeneration) return; - const fresh = getAvailableBodySpace(state); - measureAndScale(state, fresh.width, fresh.height); - }); - } else { - measureAndScale(state, availableW, availableH); + state.currentFontPx = optFont; + if (term.cols !== producerCols || term.rows !== producerRows) { + try { term.resize(producerCols, producerRows); } catch { /* ignore */ } } + requestAnimationFrame(() => { + if (generation !== state.layoutGeneration) return; + // Bail if role/producer dims changed while we were queued. + if (!state.client || state.client.isPrimary) return; + if (state.client.width !== producerCols || state.client.height !== producerRows) return; + pinBodyToNatural(state, root, body); + refineFontAfterCalibration(state, generation, producerCols, producerRows, + () => !!state.client && !state.client.isPrimary && + state.client.width === producerCols && state.client.height === producerRows); + }); + notifyToolbar(state); +} + +// On the very first calibrated render, computeOptimalFont bails out with +// state.currentFontPx (the default 13px) because cellWRatio/cellHRatio +// are still zero — those get seeded by calibrateRatios inside +// pinBodyToNatural, which runs one RAF *after* the initial layout pass. +// Result: the terminal opens at default font and only snaps to the +// right size when a ResizeObserver tick (window resize, sidebar collapse) +// re-drives layout. +// +// Once pinBodyToNatural has run, re-measure and recompute. If the +// optimal font moved (typical on first open), adjust fontSize in place +// and re-pin. We don't call applyRoleAwareLayout recursively because +// that would bump generation and could stack under fast triggers; a +// direct in-place adjustment converges in a single extra frame because +// xterm's cell metrics per font-px are stable across small font deltas. +function refineFontAfterCalibration(state, generation, cols, rows, stillApplicable) { + const term = state.term; + if (!term || !term.element) return; + const root = term.element; + const body = root.parentElement; + if (!body) return; + const fresh = getAvailableBodySpace(state); + if (fresh.width <= 0 || fresh.height <= 0) return; + const refined = computeOptimalFont(state, cols, rows, fresh.width, fresh.height); + if (refined === term.options.fontSize) return; + term.options.fontSize = refined; + forceFontRemeasure(term); + state.currentFontPx = refined; + requestAnimationFrame(() => { + if (generation !== state.layoutGeneration) return; + if (!stillApplicable()) return; + pinBodyToNatural(state, root, body); + notifyToolbar(state); + }); } function pinBodyToNatural(state, root, body) { @@ -551,8 +699,11 @@ function pinBodyToNatural(state, root, body) { const w = screenEl.offsetWidth; const h = screenEl.offsetHeight; if (w > 0 && h > 0) { - const bodyW = `${w}px`; - const bodyH = `${h}px`; + // body is border-box with padding, so pin the outer size to + // (screen dims + padding on each side) — the content area then + // matches the xterm-screen dims exactly. + const bodyW = `${w + TERMINAL_BODY_PADDING_PX * 2}px`; + const bodyH = `${h + TERMINAL_BODY_PADDING_PX * 2}px`; if (body.style.width !== bodyW || body.style.height !== bodyH) { body.style.width = bodyW; body.style.height = bodyH; @@ -574,8 +725,33 @@ function calibrateRatios(state) { const h = screenEl.offsetHeight; const fs = term.options.fontSize || state.currentFontPx; if (w > 0 && h > 0 && term.cols > 0 && term.rows > 0 && fs > 0) { - state.cellWRatio = (w / term.cols) / fs; - state.cellHRatio = (h / term.rows) / fs; + const newW = (w / term.cols) / fs; + const newH = (h / term.rows) / fs; + // Guard against transient stale readings. When fontSize was just + // changed (e.g. fit→fixed switch that jumped 13→26), xterm's DOM + // may not have re-rendered yet, so .xterm-screen still reflects + // the *old* fontSize's cell metrics. Dividing that stale pixel + // width by the new fontSize yields a ratio ~half of the true + // value. That corrupt ratio then feeds computeOptimalFont, which + // picks a wildly wrong font for the target grid. See the + // term.onResize handler in initTerminal for the matching + // RAF-deferred calibration guard. + // + // Heuristic: once we have a plausible baseline, reject any new + // sample that swings by more than 40% in either direction. Real + // xterm cell metrics per fontSize are stable across small font + // deltas (that's the whole reason we cache a ratio) so a 40% + // jump is diagnostic of a stale-render sample, not a real change. + const CALIBRATION_JUMP_TOLERANCE = 0.4; + const withinTolerance = (oldV, newV) => { + if (oldV <= 0) return true; + const delta = Math.abs(newV - oldV) / oldV; + return delta <= CALIBRATION_JUMP_TOLERANCE; + }; + if (withinTolerance(state.cellWRatio, newW) && withinTolerance(state.cellHRatio, newH)) { + state.cellWRatio = newW; + state.cellHRatio = newH; + } } } @@ -588,26 +764,78 @@ function computeOptimalFont(state, cols, rows, availW, availH) { return Math.max(MIN_FONT_PX, Math.min(MAX_FONT_PX, fs)); } +// xterm 5.5.0 only reliably re-measures cell metrics on fontFamily +// *change* — setting term.options.fontSize alone can leave stale cell +// dimensions in the renderer, so a subsequent fitAddon.fit() divides +// the available space by the old cell size and picks the wrong grid. +// Bouncing fontFamily forces the renderer to re-measure with the +// current fontSize. See the document.fonts.ready handler in +// initTerminal for the same trick applied to late font loads. +function forceFontRemeasure(term) { + if (!term) return; + try { + const family = term.options.fontFamily; + term.options.fontFamily = 'monospace'; + term.options.fontFamily = family; + } catch { /* ignore — term may be disposed */ } +} + function setFontSize(state, newSize) { newSize = Math.max(MIN_FONT_PX, Math.min(MAX_FONT_PX, newSize)); if (newSize === state.currentFontPx && state.sizeMode === 'font') return; state.currentFontPx = newSize; + // Preserve the caller's requested size as the "Fit-mode font" so the + // toolbar can show what Fit would produce even after a later fixed + // preset overwrites currentFontPx with an auto-calculated size. + state.fitFontPx = newSize; state.sizeMode = 'font'; state.fixedDims = null; - if (state.term) state.term.options.fontSize = state.currentFontPx; + if (state.term) { + state.term.options.fontSize = state.currentFontPx; + forceFontRemeasure(state.term); + } applyRoleAwareLayout(state); } function setSizeMode(state, mode, dims) { + if (window.__aspireTerminalDebug) { + console.log('[TERMDIAG] setSizeMode', { + requested: { mode, dims }, + currentSizeMode: state.sizeMode, + currentFontPx: state.currentFontPx, + fitFontPx: state.fitFontPx, + termFontSize: state.term?.options?.fontSize, + termCols: state.term?.cols, + termRows: state.term?.rows, + cellWRatio: state.cellWRatio, + cellHRatio: state.cellHRatio, + isPrimary: !!state.client?.isPrimary, + producer: state.client ? { w: state.client.width, h: state.client.height } : null, + }); + } if (mode === state.sizeMode && ((mode === 'font') || (mode === 'fixed' && dims && state.fixedDims && dims.cols === state.fixedDims.cols && dims.rows === state.fixedDims.rows))) { + if (window.__aspireTerminalDebug) { + console.log('[TERMDIAG] setSizeMode early-return'); + } return; } state.sizeMode = mode; state.fixedDims = mode === 'fixed' ? dims : null; + if (mode === 'font') { + state.currentFontPx = state.fitFontPx; + } applyRoleAwareLayout(state); + if (window.__aspireTerminalDebug) { + console.log('[TERMDIAG] setSizeMode after layout', { + currentFontPx: state.currentFontPx, + termFontSize: state.term?.options?.fontSize, + termCols: state.term?.cols, + termRows: state.term?.rows, + }); + } } // Computes the current toolbar state snapshot and (when changed) pushes @@ -696,59 +924,8 @@ function buildToolbarSnapshot(state) { }; } -function measureAndScale(state, availableW, availableH) { - const term = state.term; - if (!term || !state.client) return; - const root = term.element; - if (!root) return; - const body = root.parentElement; - if (!body) return; - - const screenEl = - root.querySelector('.xterm-screen') || - root.querySelector('canvas.xterm-text-layer') || - root; - const naturalWidth = screenEl.offsetWidth; - const naturalHeight = screenEl.offsetHeight; - - if (naturalWidth <= 0 || naturalHeight <= 0 || - availableW <= 0 || availableH <= 0) { - return; - } - - const scale = Math.min( - availableW / naturalWidth, - availableH / naturalHeight); - - if (scale <= 0) return; - - const xtermTransform = `scale(${scale})`; - const xtermW = `${naturalWidth}px`; - const xtermH = `${naturalHeight}px`; - if (root.style.transform !== xtermTransform || - root.style.width !== xtermW || - root.style.height !== xtermH) { - root.style.transformOrigin = 'top left'; - root.style.transform = xtermTransform; - root.style.width = xtermW; - root.style.height = xtermH; - } - - // Math.floor + clamp to availableW/H so we never produce a body 1px - // wider than the stage from sub-pixel accumulation — a 1px overflow - // re-triggers ResizeObserver in a tight loop and looks like the - // terminal is bouncing. - const bodyW = `${Math.min(availableW, Math.floor(naturalWidth * scale))}px`; - const bodyH = `${Math.min(availableH, Math.floor(naturalHeight * scale))}px`; - if (body.style.width !== bodyW || body.style.height !== bodyH) { - body.style.width = bodyW; - body.style.height = bodyH; - } -} - -// "Take control" handler. Clears any secondary lock-and-scale styling -// then RequestPrimary at our current grid dims so the producer resizes -// the PTY to match what we just laid out. +// "Take control" handler. RequestPrimary at our current grid dims so +// the producer resizes the PTY to match what we just laid out. function takePrimary(state) { const client = state.client; const term = state.term; @@ -802,6 +979,12 @@ export async function initTerminal(element, wsUrl, dotNetRef) { sizeMode: 'font', fixedDims: null, currentFontPx: DEFAULT_FONT_PX, + // Font size that "Fit" mode uses, tracked separately from + // currentFontPx because fixed-preset layout overwrites the latter + // with the auto-calculated optimal font. Preserving the user's last + // font-mode font here lets setSizeMode('font') restore it when the + // user flips back to Fit. + fitFontPx: DEFAULT_FONT_PX, cellWRatio: 0, cellHRatio: 0, layoutGeneration: 0, @@ -816,6 +999,7 @@ export async function initTerminal(element, wsUrl, dotNetRef) { terminalFrame: null, terminalTitlebar: null, titleText: null, + dimsText: null, terminalBody: null, }; @@ -896,6 +1080,7 @@ export async function initTerminal(element, wsUrl, dotNetRef) { requestAnimationFrame(() => { calibrateRatios(state); applyRoleAwareLayout(state); + updateDimsReadout(state); }); // OSC 0 / OSC 2 / OSC 1 — terminal apps push window/icon titles via @@ -913,16 +1098,33 @@ export async function initTerminal(element, wsUrl, dotNetRef) { // viewers' fit() calls don't disturb the producer. Push fresh dims to // the toolbar and recalibrate ratios so future fixed-mode font calcs // stay accurate. + // + // Recalibration is deferred one RAF because xterm dispatches onResize + // *before* it re-renders .xterm-screen; measuring offsetWidth here + // would divide the old rendered width by the new cols count and yield + // a cellWRatio ~half of the true value. That in turn made the toolbar's + // Fit preview report roughly double the real cols×rows. term.onResize(({ cols, rows }) => { if (state.client) state.client.sendResize(cols, rows); - calibrateRatios(state); - notifyToolbar(state); + updateDimsReadout(state); + requestAnimationFrame(() => { + if (state.term !== term) return; + calibrateRatios(state); + notifyToolbar(state); + }); }); + // User input auto-promotes to primary. Consolidating the toolbar + // into the ⋯ menu removed the explicit "Take control" button, so we + // rely on the same auto-promote path as font/size changes: if the + // viewer types (or pastes, or hits Enter), they take primary before + // the input goes out. Server drops non-primary input, so promoting + // first ensures the keystroke lands. No-ops when we're already + // primary or the client isn't connected yet. term.onData((data) => { - if (state.client) { - state.client.sendInput(textEncoder.encode(data)); - } + if (!state.client) return; + maybeAutoPromote(state); + state.client.sendInput(textEncoder.encode(data)); }); // Re-layout on container size change (window resize, sidebar collapse, @@ -1068,8 +1270,8 @@ function connectClient(state, wsUrl) { if (myGeneration !== state.reconnect.generation) return; dbg(state, 'client.onResize', { cols, rows }); // Producer's grid changed (only happens via primary's Resize). - // For secondaries this is the trigger to re-lock-and-scale to - // the new dims. + // For secondaries this is the trigger to re-fit the frame to + // the new producer dims. applyRoleAwareLayout(state); }; @@ -1127,11 +1329,13 @@ function connectClient(state, wsUrl) { scheduleReconnect(state); } } + + return myGeneration; } export function reconnectTerminal(id, wsUrl) { const state = terminals.get(id); - if (!state) return; + if (!state) return 0; dbg(state, 'reconnectTerminal (Razor explicit)', { wsUrl }); @@ -1139,7 +1343,7 @@ export function reconnectTerminal(id, wsUrl) { // Reset the backoff so we connect immediately rather than waiting // for the next pending auto-reconnect timer slot. state.reconnect.attempts = 0; - connectClient(state, wsUrl); + return connectClient(state, wsUrl); } export function disposeTerminal(id) { @@ -1201,12 +1405,6 @@ export function getSizePresets() { return SIZE_PRESETS.map((p) => ({ value: p.value, label: p.label, cols: p.cols, rows: p.rows })); } -export function takePrimaryFromHost(id) { - const state = terminals.get(id); - if (!state) return; - takePrimary(state); -} - export function setFontSizeFromHost(id, newSize) { const state = terminals.get(id); if (!state || typeof newSize !== 'number') return; @@ -1264,3 +1462,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); +} diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 149a31dc98a..2da6176b1fa 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -8,7 +8,7 @@ @@ -18,7 +18,7 @@ MainContentStyle="margin-top: 10px;" MobileToolbarButtonText="@Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsSelectResourceToolbar)]"> -

@Loc[_selectedResourceHasTerminal ? nameof(Dashboard.Resources.ConsoleLogs.TerminalHeader) : nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsHeader)]

+

@Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsHeader)]

- @if (!_selectedResourceHasTerminal) + @if (_activeView == ConsoleLogsView.Console) { - - - @TerminalPrimaryButtonLabel(terminalState.Status) - - - - @terminalState.FontPx - + - - @if (_terminalSizePresets.Count > 0) + // This takes up too much horizontal space on mobile, so show on a new line on mobile + @if (_activeView == ConsoleLogsView.Console) { - + @PageViewModel.Status } - @if (terminalState.Cols > 0 && terminalState.Rows > 0) + @if (_activeView == ConsoleLogsView.Console || _selectedResourceHasTerminal) { - - @terminalState.Cols × @terminalState.Rows - + @if (_activeView == ConsoleLogsView.Console) + { + + } + + } } - - @if (ViewportInformation.IsDesktop) + else if (_activeView == ConsoleLogsView.Console || _selectedResourceHasTerminal) { - // This takes up too much horizontal space on mobile, so show on a new line on mobile - @if (!_selectedResourceHasTerminal) + @if (_activeView == ConsoleLogsView.Console) { - @PageViewModel.Status - - - } - } - else if (!_selectedResourceHasTerminal) - { - - @if (!_selectedResourceHasTerminal) + @if (_activeView == ConsoleLogsView.Console) { @if (PageViewModel.SelectedResource?.Id is not null) @@ -168,25 +132,46 @@ + @* 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) { - +
+ +
+
+ +
} else { + @ref="_logViewerRef" + LogEntries="@_logEntries" + FilterText="@_logFilter" + ShowTimestamp="@_showTimestamp" + IsTimestampUtc="@_isTimestampUtc" + NoWrapLogs="@_noWrapLogs" + ShowNoLogsMessage="@_showNoLogsMessage" + ShowResourcePrefix="@_isSubscribedToAll"/> }
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 2ef1e4ca277..3387351d047 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -20,7 +20,6 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; -using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; @@ -170,6 +169,22 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in private Controls.TerminalToolbarState? _terminalToolbarState; private IReadOnlyList _terminalSizePresets = Array.Empty(); + // View toggle for terminal resources. The page surfaces both LogViewer + // and TerminalView in MainSection (both stay mounted so flipping does + // not tear down the PTY or the log subscription) and uses CSS to hide + // the inactive one. For non-terminal resources only LogViewer is shown + // and this field is unused. The view is purely user-controlled — the + // page defaults to Console on resource selection and only changes via + // the ⋯ menu picker. There is no auto-switching in either direction: + // hosting messages (WaitFor, startup failures, stop/exit output) stay + // visible on Console until the user explicitly clicks Terminal. + private ConsoleLogsView _activeView = ConsoleLogsView.Console; + // Tracks the view that was rendered to the DOM on the previous render + // pass. When the active view flips back to Terminal we need to nudge + // xterm.js to relayout because the wrapper's display:none → visible + // transition may not trigger ResizeObserver in every browser. + private ConsoleLogsView? _lastRenderedView; + // UI private SelectViewModel _allResource = null!; private AspirePageContentLayout? _contentLayout; @@ -457,6 +472,21 @@ _terminalViewRef is { } terminalView && { await terminalView.RefreshToolbarStateAsync(); } + + // Detect a view-flip TO Terminal and prod xterm to relayout. The + // wrapper element transitions from display:none to visible on this + // render and ResizeObserver is not guaranteed to fire for that + // box-tree change. Without this nudge xterm can stay sized to its + // pre-hide dimensions until the next external resize. + if (_selectedResourceHasTerminal && + _activeView == ConsoleLogsView.Terminal && + _lastRenderedView != ConsoleLogsView.Terminal && + _terminalViewRef is { } terminalForLayout) + { + await terminalForLayout.RefreshLayoutAsync(); + } + + _lastRenderedView = _activeView; } private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceName) @@ -472,6 +502,10 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa // the wrong badge/dims/dropdown for the new resource while the JS // terminal is initializing and pushing its first snapshot. _terminalToolbarState = null; + // Default the view to Console on every resource change so pre-PTY + // hosting messages (WaitFor, startup failures) are visible immediately + // on selection. The user picks Terminal explicitly from the ⋯ menu. + _activeView = ConsoleLogsView.Console; if (!isAllSelected && selectedResourceName is not null && _resourceByName.TryGetValue(selectedResourceName, out var selectedResource) && @@ -482,12 +516,13 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa _terminalResourceName = selectedResource.DisplayName; _terminalReplicaIndex = replicaIndex; Logger.LogDebug("Resource '{ResourceName}' has terminal at replica {ReplicaIndex}", selectedResourceName, replicaIndex); - - // Don't subscribe to console logs for terminal resources — - // the terminal view replaces the log viewer. - await CancelAllSubscriptionsAsync(); - _isSubscribedToAll = false; - return; + // Intentionally fall through to the normal subscription path so + // the resource's console log stream is collected even while the + // user is on the Terminal view. The Console view in the View + // dropdown shows these logs and they're needed for pre-PTY + // hosting messages (WaitFor, startup failures) and post-PTY + // exit messages — flipping to the Terminal view should never + // cause us to miss anything from the console stream. } // Cancel all existing subscriptions @@ -524,6 +559,14 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa { StartNoLogsMessageDelay(); } + + // Rebuild the options menu now that _selectedResourceHasTerminal + // reflects the new selection. The earlier UpdateMenuButtons() call + // in OnParametersSetAsync ran before this method updated the flag, + // so without this the menu keeps the previous resource's shape — + // e.g. the Console/Terminal view toggle would linger on a resource + // that has no WithTerminal(), and be missing on the reverse switch. + UpdateMenuButtons(); } private bool IsAllSelected() @@ -537,64 +580,142 @@ private void UpdateMenuButtons() _logsMenuItems.Clear(); _resourceMenuItems.Clear(); - _logsMenuItems.Add(new() - { - IsDisabled = PageViewModel.SelectedResource is null && !_isSubscribedToAll, - OnClick = DownloadLogsAsync, - Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.DownloadLogs)], - Icon = new Icons.Regular.Size16.ArrowDownload() - }); + var selectedResource = GetSelectedResource(); - _logsMenuItems.Add(new() + // View toggle (Console / Terminal): only meaningful for terminal- + // enabled resources; keeps the menu identical to today for the common + // no-terminal case. + if (_selectedResourceHasTerminal) { - IsDivider = true - }); + _logsMenuItems.Add(new() + { + OnClick = () => HandleViewChangedAsync(nameof(ConsoleLogsView.Console)), + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewConsoleOption)], + }); - var selectedResource = GetSelectedResource(); + _logsMenuItems.Add(new() + { + OnClick = () => HandleViewChangedAsync(nameof(ConsoleLogsView.Terminal)), + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)], + }); + + _logsMenuItems.Add(new() + { + IsDivider = true + }); + } - // Only show the "Hide hidden resources" menu item when viewing all resources - // Use IsAllSelected() instead of _isSubscribedToAll because UpdateMenuButtons() - // can be called before the subscription is established - if (IsAllSelected()) + if (_activeView == ConsoleLogsView.Terminal) { - CommonMenuItems.AddToggleHiddenResourcesMenuItem( - _logsMenuItems, - ControlsStringsLoc, - _showHiddenResources, - _resourceByName.Values, - SessionStorage, - EventCallback.Factory.Create(this, async - value => + // Terminal-only items: font +/- and a nested Terminal dimensions + // submenu carrying the same presets the old inline toolbar used. + // We render these unconditionally so the menu structure is stable + // even before the first toolbar-state snapshot arrives; enabled + // state and current font readout come from _terminalToolbarState + // when present. + var terminalState = _terminalToolbarState; + var fontPx = terminalState?.FontPx ?? 0; + var fontControlsEnabled = terminalState?.FontControlsEnabled ?? false; + var sizeSelectEnabled = terminalState?.SizeSelectEnabled ?? false; + + _logsMenuItems.Add(new() + { + OnClick = TerminalFontMinusAsync, + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarDecreaseFontSize)], + Icon = new Icons.Regular.Size16.Subtract(), + IsDisabled = !fontControlsEnabled || fontPx <= TerminalFontMin, + }); + + _logsMenuItems.Add(new() + { + OnClick = TerminalFontPlusAsync, + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarIncreaseFontSize)], + Icon = new Icons.Regular.Size16.Add(), + IsDisabled = !fontControlsEnabled || fontPx >= TerminalFontMax, + }); + + if (_terminalSizePresets.Count > 0) + { + var nested = new List(); + foreach (var preset in _terminalSizePresets) { - _showHiddenResources = value; - UpdateResourcesList(); - UpdateMenuButtons(); + var value = preset.Value; + nested.Add(new() + { + OnClick = () => TerminalSizeChangedAsync(value), + Text = preset.Label, + IsDisabled = !sizeSelectEnabled, + }); + } - await this.RefreshIfMobileAsync(_contentLayout); - })); + _logsMenuItems.Add(new() + { + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)], + Icon = new Icons.Regular.Size16.ArrowExpand(), + NestedMenuItems = nested, + }); + } } - - _logsMenuItems.Add(new() + else { - OnClick = () => ToggleTimestampAsync(showTimestamp: !_showTimestamp, isTimestampUtc: _isTimestampUtc), - Text = _showTimestamp ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampHide)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShow)], - Icon = new Icons.Regular.Size16.CalendarClock() - }); + // Console-view items: preserved from the original menu. + _logsMenuItems.Add(new() + { + IsDisabled = PageViewModel.SelectedResource is null && !_isSubscribedToAll, + OnClick = DownloadLogsAsync, + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.DownloadLogs)], + Icon = new Icons.Regular.Size16.ArrowDownload() + }); - _logsMenuItems.Add(new() - { - OnClick = () => ToggleTimestampAsync(showTimestamp: _showTimestamp, isTimestampUtc: !_isTimestampUtc), - Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShowUtc)], - Icon = _isTimestampUtc ? new Icons.Regular.Size16.CheckboxChecked() : new Icons.Regular.Size16.CheckboxUnchecked(), - IsDisabled = !_showTimestamp - }); + _logsMenuItems.Add(new() + { + IsDivider = true + }); - _logsMenuItems.Add(new() - { - OnClick = () => ToggleWrapLogsAsync(noWrapLogs: !_noWrapLogs), - Text = _noWrapLogs ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWrapLogs)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoWrapLogs)], - Icon = _noWrapLogs ? new Icons.Regular.Size16.TextWrap() : new Icons.Regular.Size16.TextWrapOff() - }); + // Only show the "Hide hidden resources" menu item when viewing all resources + // Use IsAllSelected() instead of _isSubscribedToAll because UpdateMenuButtons() + // can be called before the subscription is established + if (IsAllSelected()) + { + CommonMenuItems.AddToggleHiddenResourcesMenuItem( + _logsMenuItems, + ControlsStringsLoc, + _showHiddenResources, + _resourceByName.Values, + SessionStorage, + EventCallback.Factory.Create(this, async + value => + { + _showHiddenResources = value; + UpdateResourcesList(); + UpdateMenuButtons(); + + await this.RefreshIfMobileAsync(_contentLayout); + })); + } + + _logsMenuItems.Add(new() + { + OnClick = () => ToggleTimestampAsync(showTimestamp: !_showTimestamp, isTimestampUtc: _isTimestampUtc), + Text = _showTimestamp ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampHide)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShow)], + Icon = new Icons.Regular.Size16.CalendarClock() + }); + + _logsMenuItems.Add(new() + { + OnClick = () => ToggleTimestampAsync(showTimestamp: _showTimestamp, isTimestampUtc: !_isTimestampUtc), + Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsTimestampShowUtc)], + Icon = _isTimestampUtc ? new Icons.Regular.Size16.CheckboxChecked() : new Icons.Regular.Size16.CheckboxUnchecked(), + IsDisabled = !_showTimestamp + }); + + _logsMenuItems.Add(new() + { + OnClick = () => ToggleWrapLogsAsync(noWrapLogs: !_noWrapLogs), + Text = _noWrapLogs ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWrapLogs)] : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoWrapLogs)], + Icon = _noWrapLogs ? new Icons.Regular.Size16.TextWrap() : new Icons.Regular.Size16.TextWrapOff() + }); + } if (selectedResource != null) { @@ -1228,11 +1349,56 @@ private async Task OnTerminalToolbarStateChangedAsync(Controls.TerminalToolbarSt .ToList(); } + UpdateMenuButtons(); + StateHasChanged(); + } + + private Task HandleViewChangedAsync(string? newView) + { + if (newView is null) + { + return Task.CompletedTask; + } + + // Parse defensively so a bad enum value can't tear down the page. + // The menu-item click handlers pass nameof(...) literals today, but + // this indirection keeps the entry point safe if it grows a new + // caller. + if (!Enum.TryParse(newView, ignoreCase: true, out var parsed)) + { + return Task.CompletedTask; + } + + // Menu items for Terminal are only rendered when the current + // resource supports terminal (see UpdateMenuButtons — Terminal + // menu items are gated on _selectedResourceHasTerminal). But + // a Terminal click can still land here after the selection has + // moved to a non-terminal resource (e.g. the user clicked + // Terminal on the menu, then the resource-selector re-selected + // a shell-less resource before this handler ran). Switching to + // Terminal in that state hides the search/menu toolbar (the + // razor markup only renders it for Console view or + // terminal-enabled resources) and leaves the page in a broken + // state until the user selects another resource. Fall back to + // Console when the current selection can't host a terminal. + if (parsed == ConsoleLogsView.Terminal && !_selectedResourceHasTerminal) + { + parsed = ConsoleLogsView.Console; + } + + _activeView = parsed; + UpdateMenuButtons(); StateHasChanged(); + return Task.CompletedTask; } - private Task TerminalTakeControlAsync() - => _terminalViewRef?.TakePrimaryAsync() ?? Task.CompletedTask; + // Test-only accessors. The view-toggle behavior is reachable from bUnit + // only by inspecting the internal state — the user-visible signal + // (display:none on a wrapper div) is awkward to assert against in + // bUnit. These mirror existing internal hooks (e.g. _logEntries) used + // by ConsoleLogsTests. + internal ConsoleLogsView ActiveViewForTest => _activeView; + internal Task HandleViewChangedForTestAsync(string? newView) => HandleViewChangedAsync(newView); private Task TerminalFontMinusAsync() { @@ -1261,32 +1427,6 @@ private Task TerminalSizeChangedAsync(string? newKey) return _terminalViewRef.SetSizeModeAsync(newKey); } - // Consolidated primary/take-control toggle. Appearance reflects the - // current role (Accent = you are primary, Neutral = not), and the label - // reflects the action available (or the current state when no action is - // possible). Disabled state comes from CanTakeControl in the snapshot. - private static Appearance TerminalPrimaryButtonAppearance(string status) => status switch - { - "primary" => Appearance.Accent, - _ => Appearance.Neutral, - }; - - private string TerminalPrimaryButtonLabel(string status) => status switch - { - "primary" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarPrimaryLabel)], - "connecting" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarConnectingLabel)], - _ => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarTakeControlLabel)], - }; - - private string TerminalPrimaryButtonTitle(string status) => status switch - { - "primary" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarPrimaryTitle)], - "no-primary" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarNoPrimaryTitle)], - "viewer" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarViewerTitle)], - "connecting" => Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarConnectingTitle)], - _ => status, - }; - // IComponentWithTelemetry impl public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.ConsoleLogs); @@ -1296,4 +1436,17 @@ public void UpdateTelemetryProperties() new ComponentTelemetryProperty(TelemetryPropertyKeys.ConsoleLogsShowTimestamp, new AspireTelemetryProperty(_showTimestamp, AspireTelemetryPropertyType.UserSetting)) ], Logger); } + + /// + /// The two MainSection contents the page can show + /// for a resource that has WithTerminal() applied. Non-terminal + /// resources implicitly always show . + /// + public enum ConsoleLogsView + { + /// The resource's standard log stream (LogViewer). + Console, + /// The interactive xterm.js terminal (TerminalView). + Terminal, + } } diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index a36c9aedb85..ff19d46fa5b 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -189,12 +189,6 @@ public static string TerminalToolbarDecreaseFontSize { } } - public static string TerminalToolbarFontSize { - get { - return ResourceManager.GetString("TerminalToolbarFontSize", resourceCulture); - } - } - public static string TerminalToolbarIncreaseFontSize { get { return ResourceManager.GetString("TerminalToolbarIncreaseFontSize", resourceCulture); @@ -213,63 +207,15 @@ public static string TerminalToolbarGridSizeAuto { } } - public static string TerminalToolbarCurrentGrid { - get { - return ResourceManager.GetString("TerminalToolbarCurrentGrid", resourceCulture); - } - } - - public static string TerminalToolbarPrimaryLabel { - get { - return ResourceManager.GetString("TerminalToolbarPrimaryLabel", resourceCulture); - } - } - - public static string TerminalToolbarConnectingLabel { - get { - return ResourceManager.GetString("TerminalToolbarConnectingLabel", resourceCulture); - } - } - - public static string TerminalToolbarTakeControlLabel { - get { - return ResourceManager.GetString("TerminalToolbarTakeControlLabel", resourceCulture); - } - } - - public static string TerminalToolbarPrimaryTitle { - get { - return ResourceManager.GetString("TerminalToolbarPrimaryTitle", resourceCulture); - } - } - - public static string TerminalToolbarNoPrimaryTitle { - get { - return ResourceManager.GetString("TerminalToolbarNoPrimaryTitle", resourceCulture); - } - } - - public static string TerminalToolbarViewerTitle { - get { - return ResourceManager.GetString("TerminalToolbarViewerTitle", resourceCulture); - } - } - - public static string TerminalToolbarConnectingTitle { - get { - return ResourceManager.GetString("TerminalToolbarConnectingTitle", resourceCulture); - } - } - - public static string TerminalHeader { + public static string ConsoleLogsViewConsoleOption { get { - return ResourceManager.GetString("TerminalHeader", resourceCulture); + return ResourceManager.GetString("ConsoleLogsViewConsoleOption", resourceCulture); } } - public static string TerminalPageTitle { + public static string ConsoleLogsViewTerminalOption { get { - return ResourceManager.GetString("TerminalPageTitle", resourceCulture); + return ResourceManager.GetString("ConsoleLogsViewTerminalOption", resourceCulture); } } } diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 3bdba5face6..9288b6421cb 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -192,47 +192,21 @@ Decrease font size - - Terminal font size - Increase font size - Terminal grid size + Terminal dimensions - Auto - - - Current terminal grid - - - Primary - - - Connecting… - - - Take control - - - This tab owns primary input on the terminal session. + Fit - - No client currently owns primary input. Click to take it. - - - Another client owns primary input. Click to take control. - - - Connecting to the terminal session… + + Console logs + Option in the View dropdown that shows the resource's console logs. - + Terminal - - - {0} terminal - {0} is an application name + Option in the View dropdown that shows the resource's interactive terminal. diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 60bd4f5b1fd..3dd7a86b9ee 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -97,6 +97,16 @@ Neznámý stav + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Sledují se protokoly... @@ -117,49 +127,19 @@ Stav protokolu služby - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index fb028495b62..71ad2eafd72 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -97,6 +97,16 @@ Unbekannter Status + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Protokolle werden überwacht... @@ -117,49 +127,19 @@ Status des Dienstprotokolls - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index 41e376029a3..e361d6ee099 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -97,6 +97,16 @@ Estado desconocido + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Visualizando registros... @@ -117,49 +127,19 @@ Estado del registro de servicio - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index 1ab093c6474..1dcbe8f181c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -97,6 +97,16 @@ État inconnu + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Surveillance en cours des journaux... Merci de patienter. @@ -117,49 +127,19 @@ État du journal de service - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index 116fb10ba55..de00e65ba04 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -97,6 +97,16 @@ Stato sconosciuto + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Controllo dei log in corso... @@ -117,49 +127,19 @@ Stato del log del servizio - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index 53ed5f2d81d..397bdf9c58e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -97,6 +97,16 @@ 不明な状態 + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... ログを監視しています... @@ -117,49 +127,19 @@ サービス ログの状態 - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index 722abd1cf61..d9908c7a459 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -97,6 +97,16 @@ 알 수 없는 상태 + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... 로그를 보는 중... @@ -117,49 +127,19 @@ 서비스 로그 상태 - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index b4ad9dca8e2..b1ca6622bd7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -97,6 +97,16 @@ Nieznany stan + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Trwa oglądanie dzienników... @@ -117,49 +127,19 @@ Stan dziennika usługi - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 4fec01d0601..58182d67e5e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -97,6 +97,16 @@ Estado desconhecido + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Observando logs... @@ -117,49 +127,19 @@ Status de log de serviço - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index 850b0f15678..00a64167336 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -97,6 +97,16 @@ Неизвестное состояние + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Просмотр журналов... @@ -117,49 +127,19 @@ Состояние журнала службы - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 3f4d41f4d4e..5ef8ce0ff35 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -97,6 +97,16 @@ Bilinmeyen durum + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... Günlükler izleniyor... @@ -117,49 +127,19 @@ Hizmet günlüğü durumu - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index c436574ef30..dad70d2a09c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -97,6 +97,16 @@ 未知状态 + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... 正在监视日志... @@ -117,49 +127,19 @@ 服务日志状态 - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index 2e26c7c5677..2aec48e1d44 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -97,6 +97,16 @@ 未知狀態 + + Console logs + Console logs + Option in the View dropdown that shows the resource's console logs. + + + Terminal + Terminal + Option in the View dropdown that shows the resource's interactive terminal. + Watching logs... 正在監看記錄... @@ -117,49 +127,19 @@ 服務記錄狀態 - - Terminal - Terminal - - - - {0} terminal - {0} terminal - {0} is an application name - - - Connecting… - Connecting… - - - - Connecting to the terminal session… - Connecting to the terminal session… - - - - Current terminal grid - Current terminal grid - - Decrease font size Decrease font size - - Terminal font size - Terminal font size - - - Terminal grid size - Terminal grid size + Terminal dimensions + Terminal dimensions - Auto - Auto + Fit + Fit @@ -167,31 +147,6 @@ Increase font size - - No client currently owns primary input. Click to take it. - No client currently owns primary input. Click to take it. - - - - Primary - Primary - - - - This tab owns primary input on the terminal session. - This tab owns primary input on the terminal session. - - - - Take control - Take control - - - - Another client owns primary input. Click to take control. - Another client owns primary input. Click to take control. - - \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index cb849fd482d..5307cd1e345 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Model; using Aspire.Dashboard.Tests.Shared; @@ -19,8 +20,9 @@ namespace Aspire.Dashboard.Components.Tests.Pages; // Focused bUnit coverage for the central user-visible render branch in // ConsoleLogs.razor: when the selected resource has WithTerminal() applied, -// the page must mount TerminalView instead of LogViewer (and restore LogViewer -// when switching back to a non-terminal resource). The HasTerminal() +// the page must mount BOTH TerminalView and LogViewer (the two are toggled +// via the View dropdown; both stay mounted so neither tears down on flips). +// For non-terminal resources only LogViewer is mounted. The HasTerminal() // predicate itself has unit coverage in ResourceViewModelExtensionsTerminalTests, // but only a component-level test proves the page actually re-evaluates the // flag on selection change and that the render branch wires the correct @@ -36,7 +38,7 @@ namespace Aspire.Dashboard.Components.Tests.Pages; public partial class ConsoleLogsTests { [Fact] - public async Task TerminalResource_Selected_RendersTerminalView_NotLogViewer() + public async Task TerminalResource_Selected_RendersBothViews_DefaultsToConsole() { var consoleLogsChannel = Channel.CreateUnbounded>(); var resourceChannel = Channel.CreateUnbounded>(); @@ -66,17 +68,18 @@ public async Task TerminalResource_Selected_RendersTerminalView_NotLogViewer() var instance = cut.Instance; cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name); // Page wires _selectedResourceHasTerminal in SubscribeAsync after the - // selection update; wait for that to flip true before asserting the - // rendered tree, otherwise we can race the initial render. + // selection update; wait for the dual-mount branch to take effect. cut.WaitForState(() => cut.FindComponents().Count > 0); - var terminalViews = cut.FindComponents(); - var logViewers = cut.FindComponents(); - - Assert.Single(terminalViews); - Assert.Empty(logViewers); + // Both views are mounted concurrently for terminal-enabled resources + // so the View dropdown can flip between them without tearing down + // the JS terminal or the LogViewer subscription. The initial active + // view is Console — that way any pre-PTY hosting messages (WaitFor) + // are visible immediately. + Assert.Single(cut.FindComponents()); + Assert.Single(cut.FindComponents()); - var terminalView = terminalViews[0].Instance; + var terminalView = cut.FindComponents()[0].Instance; Assert.Equal(terminalResource.DisplayName, terminalView.ResourceName); Assert.Equal(0, terminalView.ReplicaIndex); @@ -84,7 +87,7 @@ public async Task TerminalResource_Selected_RendersTerminalView_NotLogViewer() } [Fact] - public async Task SwitchingFromTerminalToNonTerminalResource_RestoresLogViewer() + public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalView() { var consoleLogsChannel = Channel.CreateUnbounded>(); var resourceChannel = Channel.CreateUnbounded>(); @@ -116,9 +119,9 @@ public async Task SwitchingFromTerminalToNonTerminalResource_RestoresLogViewer() cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name); cut.WaitForState(() => cut.FindComponents().Count > 0); - // Sanity: starting state is TerminalView, no LogViewer. + // Sanity: terminal resource mounts both views. Assert.Single(cut.FindComponents()); - Assert.Empty(cut.FindComponents()); + Assert.Single(cut.FindComponents()); // Switch to the plain resource. Use the same ResourceSelect-driven // path as ResourceName_SubscribeOnLoadAndChange_* so we exercise the @@ -135,8 +138,9 @@ public async Task SwitchingFromTerminalToNonTerminalResource_RestoresLogViewer() innerSelect.Change("plain-resource"); cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == plainResource.Name); - // LogViewer should be restored and TerminalView torn down. - cut.WaitForState(() => cut.FindComponents().Count > 0); + // For a non-terminal resource the TerminalView is not mounted at all + // (only LogViewer is needed), so the dual-mount branch is skipped. + cut.WaitForState(() => cut.FindComponents().Count == 0); Assert.Empty(cut.FindComponents()); Assert.Single(cut.FindComponents()); @@ -176,6 +180,13 @@ public async Task SwitchingFromTerminalToNonTerminalResource_RestoresSearchFilte var instance = cut.Instance; cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name); cut.WaitForState(() => cut.FindComponents().Count > 0); + + // Switch to the Terminal view via the user picker so the log filter + // is hidden (the filter only applies to LogViewer content, not to + // a live PTY). Then verify that navigating to a non-terminal + // resource restores the filter UI. + await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Terminal))); + cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal); Assert.Empty(cut.FindComponents()); navigationManager.LocationChanged += (sender, e) => @@ -211,6 +222,134 @@ public async Task SwitchingFromTerminalToNonTerminalResource_RestoresSearchFilte }); } + [Fact] + public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveView() + { + // The user-visible contract for the view flip is not the _activeView + // enum but the `display: contents` / `display: none` pair on the two + // wrapper divs in ConsoleLogs.razor. Lock down that mapping so a + // future edit that inverts the ternary or swaps the wrappers can't + // pass the enum-based tests while producing a broken UI. + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: _ => consoleLogsChannel, + resourceChannelProvider: () => resourceChannel, + initialResources: [terminalResource]); + + SetupConsoleLogsServices(dashboardClient); + SetupTerminalViewJsInterop(); + + var navigationManager = Services.GetRequiredService(); + navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource")); + + var dimensionManager = Services.GetRequiredService(); + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ResourceName, "terminal-resource"); + builder.Add(p => p.ViewportInformation, viewport); + }); + + var instance = cut.Instance; + cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name); + cut.WaitForState(() => cut.FindComponents().Count > 0); + + // Initial state: page defaults to Console. The Console wrapper (the + // one containing LogViewer) must be visible; the Terminal wrapper + // must be hidden. Locate each wrapper by descending from the + // component's rendered element. + var (consoleWrapper, terminalWrapper) = FindViewWrappers(cut); + Assert.Contains("display: contents", consoleWrapper.GetAttribute("style")); + Assert.Contains("display: none", terminalWrapper.GetAttribute("style")); + + // Flip to Terminal via the user-picked latch path and re-query the + // wrappers — the render pass must swap the two `display` values. + await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Terminal))); + cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal); + + (consoleWrapper, terminalWrapper) = FindViewWrappers(cut); + Assert.Contains("display: none", consoleWrapper.GetAttribute("style")); + Assert.Contains("display: contents", terminalWrapper.GetAttribute("style")); + } + + [Fact] + public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitialization() + { + var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); + var initTerminal = module.Setup("initTerminal", _ => true); + var reconnectTerminal = module.Setup("reconnectTerminal", _ => true); + reconnectTerminal.SetResult(2); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ResourceName, "first-resource"); + builder.Add(p => p.ReplicaIndex, 0); + }); + + cut.SetParametersAndRender(builder => + { + builder.Add(p => p.ResourceName, "second-resource"); + builder.Add(p => p.ReplicaIndex, 1); + }); + + initTerminal.SetResult(1); + + cut.WaitForAssertion(() => + { + var init = Assert.Single(initTerminal.Invocations); + var reconnect = Assert.Single(reconnectTerminal.Invocations); + var initUrl = Assert.IsType(init.Arguments[1]); + var reconnectUrl = Assert.IsType(reconnect.Arguments[1]); + + Assert.Contains("resource=first-resource", initUrl); + Assert.Contains("replica=0", initUrl); + Assert.Equal(1, reconnect.Arguments[0]); + Assert.Contains("resource=second-resource", reconnectUrl); + Assert.Contains("replica=1", reconnectUrl); + }); + } + + private static (AngleSharp.Dom.IElement Console, AngleSharp.Dom.IElement Terminal) FindViewWrappers( + Bunit.IRenderedComponent cut) + { + // LogViewer renders
as its root and + // TerminalView renders
. The two + // Console/Terminal wrappers are the enclosing divs that carry the + // `display: contents;` / `display: none;` inline style bound to + // _activeView. Walk up from each component root until we hit that + // wrapper — the raw inline style is the user-visible flip contract. + var logRoot = cut.Find(".log-overflow"); + var terminalRoot = cut.Find(".terminal-container"); + + var consoleWrapper = FindWrapperWithDisplayStyle(logRoot); + var terminalWrapper = FindWrapperWithDisplayStyle(terminalRoot); + + Assert.NotNull(consoleWrapper); + Assert.NotNull(terminalWrapper); + return (consoleWrapper!, terminalWrapper!); + } + + private static AngleSharp.Dom.IElement? FindWrapperWithDisplayStyle(AngleSharp.Dom.IElement start) + { + var current = start.ParentElement; + while (current is not null) + { + var style = current.GetAttribute("style") ?? string.Empty; + if (current.TagName.Equals("DIV", StringComparison.OrdinalIgnoreCase) && + style.Contains("display:", StringComparison.Ordinal)) + { + return current; + } + current = current.ParentElement; + } + return null; + } + private void SetupTerminalViewJsInterop() { // TerminalView.OnAfterRenderAsync does: @@ -223,7 +362,10 @@ private void SetupTerminalViewJsInterop() // about runtime terminal behaviour. var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); + module.Setup("reconnectTerminal", _ => true).SetResult(2); module.SetupVoid("disposeTerminal", _ => true).SetVoidResult(); + module.SetupVoid("refreshLayout", _ => true).SetVoidResult(); + module.SetupVoid("refreshToolbarState", _ => true).SetVoidResult(); module.Setup("getSizePresets").SetResult([]); }