Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ConsoleLogConsoleSettings>(BrowserStorageKeys.ConsoleLogConsoleSettings);
Expand Down Expand Up @@ -438,7 +448,7 @@ protected override async Task OnParametersSetAsync()

if (needsNewSubscription)
{
await SubscribeAsync(isAllSelected, selectedResourceName);
await SubscribeAsync(isAllSelected, selectedResourceName, resetView: true);
}

UpdateTelemetryProperties();
Expand Down Expand Up @@ -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);

Expand All @@ -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) &&
Expand All @@ -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.
Comment thread
mitchdenny marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReadOnlyList<ResourceLogLine>>();
var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
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,
Expand Down Expand Up @@ -73,19 +73,116 @@ 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<TerminalView>());
Assert.Single(cut.FindComponents<LogViewer>());

// 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<TerminalView>()[0].Instance;
Assert.Equal(terminalResource.DisplayName, terminalView.ResourceName);
Assert.Equal(0, terminalView.ReplicaIndex);

await Task.CompletedTask;
}

[Fact]
public async Task TerminalResource_NotLive_Selected_RendersBothViews_DefaultsToConsole()
{
var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
// 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>();
navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));

var dimensionManager = Services.GetRequiredService<DimensionManager>();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
dimensionManager.InvokeOnViewportInformationChanged(viewport);

var cut = RenderComponent<Components.Pages.ConsoleLogs>(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<TerminalView>().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<TerminalView>());
Assert.Single(cut.FindComponents<LogViewer>());
Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest);

await Task.CompletedTask;
}

[Fact]
public async Task TerminalResource_Live_ManualConsoleSelection_SurvivesFilterChange()
{
var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
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>();
navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));

var dimensionManager = Services.GetRequiredService<DimensionManager>();
var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
dimensionManager.InvokeOnViewportInformationChanged(viewport);

var cut = RenderComponent<Components.Pages.ConsoleLogs>(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<TerminalView>().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<ConsoleLogsManager>();
await cut.InvokeAsync(() => consoleLogsManager.UpdateFiltersAsync(ConsoleLogsFilters.CreateClearAll(DateTime.UtcNow)));

Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest);
}

[Fact]
public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalView()
{
Expand Down Expand Up @@ -232,7 +329,10 @@ public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveVi
// pass the enum-based tests while producing a broken UI.
var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
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,
Expand Down Expand Up @@ -369,7 +469,7 @@ private void SetupTerminalViewJsInterop()
module.Setup<TerminalSizePreset[]>("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
Expand All @@ -385,7 +485,7 @@ private static ResourceViewModel CreateTerminalResource(string resourceName, int

return ModelTestHelpers.CreateResource(
resourceName: resourceName,
state: KnownResourceState.Running,
state: state,
properties: properties);
}

Expand Down
Loading