diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index c5a53d55f36..aff7ec5abda 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -162,11 +162,18 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in // 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. + // and this field is unused. + // + // The default is chosen once per resource selection (see SubscribeAsync): + // a live (Running) terminal resource defaults to Terminal because its PTY + // is the surface the user came for, while every other case (non-terminal, + // or a terminal resource that isn't live yet or has already exited) + // defaults to Console so pre-PTY hosting messages (WaitFor, startup + // failures) and post-PTY exit output stay visible. After that initial + // default there is no state-driven auto-switching: later refreshes such + // as a filter/clear change (which also route through SubscribeAsync) + // preserve the current view, and the user can flip either way via the + // ⋯ menu picker. 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 @@ -208,7 +215,10 @@ protected override async Task OnInitializedAsync() var isAllSelected = IsAllSelected(); var selectedResourceName = PageViewModel.SelectedResource.Id?.InstanceId; - await SubscribeAsync(isAllSelected, selectedResourceName); + // A filter change (e.g. clearing the console logs) is not a resource + // selection change, so preserve the user's current view instead of + // snapping back to the default. + await SubscribeAsync(isAllSelected, selectedResourceName, resetView: false); }); var consoleSettingsResult = await LocalStorage.GetUnprotectedAsync(BrowserStorageKeys.ConsoleLogConsoleSettings); @@ -438,7 +448,7 @@ protected override async Task OnParametersSetAsync() if (needsNewSubscription) { - await SubscribeAsync(isAllSelected, selectedResourceName); + await SubscribeAsync(isAllSelected, selectedResourceName, resetView: true); } UpdateTelemetryProperties(); @@ -477,7 +487,7 @@ _terminalViewRef is { } terminalView && _lastRenderedView = _activeView; } - private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceName) + private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceName, bool resetView) { Logger.LogDebug("Subscription change needed. IsAllSelected: {IsAllSelected}, SelectedResource: {SelectedResource}", isAllSelected, selectedResourceName); @@ -489,10 +499,23 @@ 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; + + // Only (re)default the view on an actual resource-selection change. + // SubscribeAsync also runs on filter changes (clearing the console logs + // routes through ConsoleLogsManager.OnFiltersChanged), and those + // refreshes must preserve whatever view the user is currently on rather + // than snapping back to the default. + if (resetView) + { + // Default the view to Console on every resource change. A terminal- + // enabled resource that isn't live yet (Waiting/Starting) or has already + // stopped (Exited/Finished/FailedToStart) has no active PTY, so Console + // is where the useful output lives: pre-PTY hosting messages (WaitFor, + // startup failures) and post-PTY exit messages. When the resource is + // live (Running) the PTY is the primary surface, so we flip the default + // to Terminal below. + _activeView = ConsoleLogsView.Console; + } if (!isAllSelected && selectedResourceName is not null && _resourceByName.TryGetValue(selectedResourceName, out var selectedResource) && @@ -503,6 +526,19 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa _terminalResourceName = selectedResource.DisplayName; _terminalReplicaIndex = replicaIndex; Logger.LogDebug("Resource '{ResourceName}' has terminal at replica {ReplicaIndex}", selectedResourceName, replicaIndex); + + // When the resource is live (Running) its PTY is active, so make the + // terminal the default view on selection — that's the surface the + // user came for. Non-running terminal resources stay on Console so + // pre-PTY hosting messages and post-PTY exit output remain visible + // immediately. Gated on resetView so a filter refresh (e.g. clearing + // logs) never overrides a manual view choice. The user can still flip + // either way via the ⋯ menu. + if (resetView && selectedResource.IsRunningState()) + { + _activeView = ConsoleLogsView.Terminal; + } + // 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 diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 5307cd1e345..bfebda1128f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -38,11 +38,11 @@ namespace Aspire.Dashboard.Components.Tests.Pages; public partial class ConsoleLogsTests { [Fact] - public async Task TerminalResource_Selected_RendersBothViews_DefaultsToConsole() + public async Task TerminalResource_Live_Selected_RendersBothViews_DefaultsToTerminal() { var consoleLogsChannel = Channel.CreateUnbounded>(); var resourceChannel = Channel.CreateUnbounded>(); - var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1); + var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running); var dashboardClient = new TestDashboardClient( isEnabled: true, consoleLogsChannelProvider: _ => consoleLogsChannel, @@ -73,12 +73,15 @@ public async Task TerminalResource_Selected_RendersBothViews_DefaultsToConsole() // 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. + // the JS terminal or the LogViewer subscription. Assert.Single(cut.FindComponents()); Assert.Single(cut.FindComponents()); + // The resource is live (Running), so its PTY is active — the page + // defaults to the Terminal view because that's the surface the user + // navigated to see. + Assert.Equal(ConsoleLogs.ConsoleLogsView.Terminal, instance.ActiveViewForTest); + var terminalView = cut.FindComponents()[0].Instance; Assert.Equal(terminalResource.DisplayName, terminalView.ResourceName); Assert.Equal(0, terminalView.ReplicaIndex); @@ -86,6 +89,100 @@ public async Task TerminalResource_Selected_RendersBothViews_DefaultsToConsole() await Task.CompletedTask; } + [Fact] + public async Task TerminalResource_NotLive_Selected_RendersBothViews_DefaultsToConsole() + { + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + // A terminal-enabled resource that is still Waiting has no live PTY yet, + // so pre-PTY hosting messages (WaitFor, startup failures) belong on the + // Console view. The page must NOT flip to Terminal until the resource is + // running. + var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Waiting); + 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); + + // Both views mount regardless of state so a later Running transition + // doesn't need to re-create the terminal, but the non-live resource + // stays on Console. + Assert.Single(cut.FindComponents()); + Assert.Single(cut.FindComponents()); + Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest); + + await Task.CompletedTask; + } + + [Fact] + public async Task TerminalResource_Live_ManualConsoleSelection_SurvivesFilterChange() + { + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running); + 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); + + // The live resource defaults to Terminal, then the user deliberately + // picks Console from the ⋯ menu. + Assert.Equal(ConsoleLogs.ConsoleLogsView.Terminal, instance.ActiveViewForTest); + await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Console))); + cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Console); + + // Clearing the console logs raises ConsoleLogsManager.OnFiltersChanged, + // which re-enters SubscribeAsync. That refresh must preserve the user's + // manual Console selection rather than snapping the running resource back + // to Terminal. + var consoleLogsManager = Services.GetRequiredService(); + await cut.InvokeAsync(() => consoleLogsManager.UpdateFiltersAsync(ConsoleLogsFilters.CreateClearAll(DateTime.UtcNow))); + + Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest); + } + [Fact] public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalView() { @@ -232,7 +329,10 @@ public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveVi // 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); + // Use a non-live (Waiting) terminal resource so the page starts on the + // Console view; a Running resource would default straight to Terminal + // (its PTY is live) and we want to exercise the Console→Terminal flip. + var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Waiting); var dashboardClient = new TestDashboardClient( isEnabled: true, consoleLogsChannelProvider: _ => consoleLogsChannel, @@ -369,7 +469,7 @@ private void SetupTerminalViewJsInterop() module.Setup("getSizePresets").SetResult([]); } - private static ResourceViewModel CreateTerminalResource(string resourceName, int replicaIndex, int replicaCount) + private static ResourceViewModel CreateTerminalResource(string resourceName, int replicaIndex, int replicaCount, KnownResourceState state = KnownResourceState.Running) { // WithTerminal() stamps these three properties onto the resource // snapshot in DashboardServiceData.cs (covered by @@ -385,7 +485,7 @@ private static ResourceViewModel CreateTerminalResource(string resourceName, int return ModelTestHelpers.CreateResource( resourceName: resourceName, - state: KnownResourceState.Running, + state: state, properties: properties); }