File: Components\Pages\ConsoleLogs.razor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Threading.Channels;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Shared.ConsoleLogs;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
using MenuItemRole = Microsoft.FluentUI.AspNetCore.Components.MenuItemRole;
 
namespace Aspire.Dashboard.Components.Pages;
 
public sealed partial class ConsoleLogs : ComponentBase, IComponentWithTelemetry, IAsyncDisposable, IPageWithSessionAndUrlState<ConsoleLogs.ConsoleLogsViewModel, ConsoleLogs.ConsoleLogsPageState>
{
    private static readonly TimeSpan s_noLogsMessageDelay = TimeSpan.FromSeconds(1.5);
 
    [DebuggerDisplay("Resource = {Resource.Name}, IsCancellationRequested = {CancellationToken.IsCancellationRequested}")]
    private sealed class ConsoleLogsSubscription
    {
        private static int s_subscriptionId;
 
        private readonly CancellationTokenSource _cts = new();
        private readonly int _subscriptionId = Interlocked.Increment(ref s_subscriptionId);
        private readonly ILogger _logger;
 
        public ResourceViewModel Resource { get; }
        public Task? SubscriptionTask { get; set; }
        private long _cancelTimestamp;
 
        public CancellationToken CancellationToken => _cts.Token;
        public int SubscriptionId => _subscriptionId;
 
        public ConsoleLogsSubscription(ResourceViewModel resource, ILogger logger)
        {
            Resource = resource;
            _logger = logger;
            _cts = new();
 
            _cts.Token.Register(static state =>
            {
                // The canceled TCS lets us know that the subscription has been canceled without waiting for all other cancellation logic to finish running.
                var s = (ConsoleLogsSubscription)state!;
                s._logger.LogDebug("Canceling subscription {SubscriptionId} to {ResourceName}.", s.SubscriptionId, s.Resource.Name);
            }, this);
        }
 
        public void Cancel()
        {
            _cancelTimestamp = Stopwatch.GetTimestamp();
            _cts.Cancel();
            _logger.LogDebug("Canceling subscription for resource {ResourceName}.", Resource.Name);
        }
 
        public TimeSpan GetCancelElapsedTime() => Stopwatch.GetElapsedTime(_cancelTimestamp);
    }
 
    [Inject]
    public required IOptions<DashboardOptions> Options { get; init; }
 
    [Inject]
    public required IDashboardClient DashboardClient { get; init; }
 
    [Inject]
    public required ILocalStorage LocalStorage { get; init; }
 
    [Inject]
    public required ISessionStorage SessionStorage { get; init; }
 
    [Inject]
    public required DashboardDataSource DataSource { get; init; }
 
    public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
 
    [Inject]
    public required ILogger<ConsoleLogs> Logger { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.ConsoleLogs> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.Resources> ResourcesLoc { get; init; }
 
    [Inject]
    public required IStringLocalizer<ControlsStrings> ControlsStringsLoc { get; init; }
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required DashboardCommandExecutor DashboardCommandExecutor { get; init; }
 
    [Inject]
    public required ConsoleLogsManager ConsoleLogsManager { get; init; }
 
    [Inject]
    public required BrowserTimeProvider TimeProvider { get; init; }
 
    [Inject]
    public required PauseManager PauseManager { get; init; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; init; }
 
    [Inject]
    public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }
 
    [Inject]
    public required IconResolver IconResolver { get; init; }
 
    [Inject]
    public required DashboardDialogService DialogService { get; init; }
 
    [Inject]
    public required ResourceMenuBuilder ResourceMenuBuilder { get; init; }
 
    [CascadingParameter]
    public required ViewportInformation ViewportInformation { get; init; }
 
    [Parameter]
    public string? ResourceName { get; set; }
 
    private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, int? LineNumber);
 
    private readonly CancellationTokenSource _resourceSubscriptionCts = new();
    private readonly ConcurrentDictionary<string, ResourceViewModel> _resourceByName = new(StringComparers.ResourceName);
    private readonly Channel<LogEntryToWrite> _logEntryChannel = Channel.CreateUnbounded<LogEntryToWrite>(new UnboundedChannelOptions
    {
        SingleReader = true,
        SingleWriter = false
    });
    private ImmutableList<SelectViewModel<ResourceTypeDetails>>? _resources;
    private CancellationToken _resourceSubscriptionToken;
    private Task? _resourceSubscriptionTask;
    private Task? _logEntryChannelReaderTask;
    private readonly ConcurrentDictionary<string, ConsoleLogsSubscription> _consoleLogsSubscriptions = new(StringComparers.ResourceName);
    private bool _isSubscribedToAll;
    internal LogEntries _logEntries = null!;
    private readonly object _updateLogsLock = new object();
    private CancellationTokenSource? _showNoLogsMessageCts;
    private Task? _showNoLogsMessageDelayTask;
    private LogViewer? _logViewerRef;
    private Controls.TerminalView? _terminalViewRef;
    private bool _selectedResourceHasTerminal;
    private string? _terminalResourceName;
    private int _terminalReplicaIndex;
    private Controls.TerminalToolbarState? _terminalToolbarState;
    private IReadOnlyList<Controls.TerminalSizePreset> _terminalSizePresets = Array.Empty<Controls.TerminalSizePreset>();
 
    // 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 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
    // 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<ResourceTypeDetails> _allResource = null!;
    private AspirePageContentLayout? _contentLayout;
    private readonly List<CommandViewModel> _highlightedCommands = new();
    private readonly List<MenuButtonItem> _logsMenuItems = new();
    private readonly List<MenuButtonItem> _resourceMenuItems = new();
 
    // State
    private bool _showHiddenResources;
    private bool _showTimestamp;
    private bool _isTimestampUtc;
    private bool _noWrapLogs;
    private bool _showNoLogsMessage;
    private bool _consoleLogsWereLoaded = true;
    private string _logFilter = string.Empty;
    public ConsoleLogsViewModel PageViewModel { get; set; } = null!;
    private IDisposable? _consoleLogsFiltersChangedSubscription;
 
    public string BasePath => DashboardUrls.ConsoleLogBasePath;
    public string SessionStorageKey => BrowserStorageKeys.ConsoleLogsPageState;
 
    protected override async Task OnInitializedAsync()
    {
        TelemetryContextProvider.Initialize(TelemetryContext);
        _resourceSubscriptionToken = _resourceSubscriptionCts.Token;
        _logEntries = new(DashboardUIHelpers.GetVirtualizedItemCount(Options.Value.Frontend.MaxConsoleLogCount));
        _allResource = new() { Id = null, Name = ControlsStringsLoc[nameof(ControlsStrings.LabelAll)] };
        PageViewModel = new ConsoleLogsViewModel { SelectedResource = _allResource, Status = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsLoadingResources)] };
        _logEntryChannelReaderTask = StartLogEntryChannelReaderTask();
 
        _consoleLogsFiltersChangedSubscription = ConsoleLogsManager.OnFiltersChanged(async () =>
        {
            var isAllSelected = IsAllSelected();
            var selectedResourceName = PageViewModel.SelectedResource.Id?.InstanceId;
 
            // 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);
        if (consoleSettingsResult.Value is { } consoleSettings)
        {
            _showTimestamp = consoleSettings.ShowTimestamp;
            _isTimestampUtc = consoleSettings.IsTimestampUtc;
            _noWrapLogs = consoleSettings.NoWrapLogs;
        }
 
        var showHiddenResources = await SessionStorage.GetAsync<bool>(BrowserStorageKeys.ResourcesShowHiddenResources);
        if (showHiddenResources.Success)
        {
            _showHiddenResources = showHiddenResources.Value;
        }
 
        await ConsoleLogsManager.EnsureInitializedAsync();
 
        var loadingTcs = new TaskCompletionSource();
 
        await TrackResourceSnapshotsAsync();
 
        // Wait for resource to be selected. If selected resource isn't available after a few seconds then stop waiting.
        try
        {
            await loadingTcs.Task.WaitAsync(TimeSpan.FromSeconds(5), _resourceSubscriptionToken);
            Logger.LogDebug("Loading task completed.");
        }
        catch (OperationCanceledException)
        {
            Logger.LogDebug("Load task canceled.");
        }
        catch (Exception ex)
        {
            Logger.LogWarning(ex, "Load timeout while waiting for resource {ResourceName}.", ResourceName);
            SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsLogsNotYetAvailable));
        }
 
        async Task TrackResourceSnapshotsAsync()
        {
            if (!DashboardClient.IsEnabled)
            {
                return;
            }
 
            var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_resourceSubscriptionToken);
 
            Logger.LogDebug("Received initial resource snapshot with {ResourceCount} resources.", snapshot.Length);
 
            foreach (var resource in snapshot)
            {
                var added = _resourceByName.TryAdd(resource.Name, resource);
                Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data.");
            }
 
            UpdateResourcesList();
 
            // Set loading task result if the selected resource is already in the snapshot or there is no selected resource.
            if (ResourceName != null)
            {
                if (ResourceViewModel.TryGetResourceByName(ResourceName, _resourceByName, out var selectedResource))
                {
                    SetSelectedResourceOption(selectedResource);
                }
            }
            else
            {
                Logger.LogDebug("All resources selected.");
                loadingTcs.TrySetResult();
            }
 
            _resourceSubscriptionTask = Task.Run(async () =>
            {
                await foreach (var changes in subscription.WithCancellation(_resourceSubscriptionToken).ConfigureAwait(false))
                {
                    var selectedResourceName = PageViewModel.SelectedResource?.Id?.InstanceId;
                    var needsRender = false;
 
                    foreach (var (changeType, resource) in changes)
                    {
                        // Detect a genuine resource addition before OnResourceChanged inserts it into
                        // _resourceByName. An Upsert covers both new resources and updates to existing
                        // ones, so the map lookup distinguishes the two. Match the visibility condition
                        // used to decide subscription below so a render only happens when the addition
                        // will actually appear in the resource picker.
                        var isNewResource = changeType == ResourceViewModelChangeType.Upsert &&
                            !_resourceByName.ContainsKey(resource.Name) &&
                            !resource.IsResourceHidden(_showHiddenResources);
 
                        await OnResourceChanged(changeType, resource);
 
                        // Track whether this batch contains changes that affect the visible page UI
                        // (selected resource modified, or resources added/removed). Frequent property
                        // updates on non-selected resources (health checks, state transitions) don't
                        // require a full page re-render. Avoiding unnecessary re-renders prevents
                        // FluentSearch's ImmediateDelay input buffer from being clobbered by stale
                        // parameter values pushed during the debounce window.
                        if (changeType == ResourceViewModelChangeType.Delete ||
                            isNewResource ||
                            string.Equals(resource.Name, selectedResourceName, StringComparisons.ResourceName))
                        {
                            needsRender = true;
                        }
 
                        // the initial snapshot we obtain is [almost] never correct (it's always empty)
                        // we still want to select the user's initial queried resource on page load,
                        // so if there is no selected resource when we
                        // receive an added resource, and that added resource name == ResourceName,
                        // we should mark it as selected
                        if (ResourceName is not null && PageViewModel.SelectedResource is null && changeType == ResourceViewModelChangeType.Upsert && string.Equals(ResourceName, resource.Name, StringComparisons.ResourceName))
                        {
                            SetSelectedResourceOption(resource);
                            needsRender = true;
                        }
                    }
 
                    if (needsRender)
                    {
                        await InvokeAsync(() =>
                        {
                            // The selected resource may have changed, so update resource action buttons.
                            // Update inside the render's sync context so the buttons don't change while the UI is rendering.
                            UpdateMenuButtons();
 
                            StateHasChanged();
                        });
                    }
                }
            });
        }
 
        void SetSelectedResourceOption(ResourceViewModel resource)
        {
            PageViewModel.SelectedResource = GetSelectedOption();
 
            Logger.LogDebug("Selected console resource from name {ResourceName}.", ResourceName);
            loadingTcs.TrySetResult();
        }
    }
 
    private async Task StartLogEntryChannelReaderTask()
    {
        await foreach (var batch in _logEntryChannel.GetBatchesAsync(minReadInterval: TimeSpan.FromMilliseconds(100), cancellationToken: _resourceSubscriptionToken))
        {
            var hasNewLogEntry = false;
            lock (_updateLogsLock)
            {
                foreach (var (resourceName, logEntry, lineNumber) in batch)
                {
                    // Console logs are filtered in the UI by the timestamp of the log entry.
                    var timestampFilterDate = ConsoleLogsManager.GetFilterDate(resourceName);
 
                    if (lineNumber != null)
                    {
                        // Set the base line number using the reported line number of the first log line.
                        _logEntries.BaseLineNumber ??= lineNumber;
                    }
 
                    // Check if log entry is not displayed because of remove.
                    if (logEntry.Timestamp is not null && timestampFilterDate is not null && !(logEntry.Timestamp > timestampFilterDate))
                    {
                        continue;
                    }
 
                    // Check if log entry is not displayed because of pause.
                    if (_logEntries.ProcessPauseFilters(logEntry))
                    {
                        continue;
                    }
 
                    _logEntries.InsertSorted(logEntry);
                    hasNewLogEntry = true;
                }
            }
 
            await InvokeAsync(async () =>
            {
                if (hasNewLogEntry)
                {
                    _showNoLogsMessage = false;
                }
 
                await _logViewerRef.SafeRefreshDataAsync();
            });
        }
    }
 
    private SelectViewModel<ResourceTypeDetails> GetSelectedOption()
    {
        Debug.Assert(_resources is not null);
        return _resources.GetResource(Logger, ResourceName, canSelectGrouping: true, fallbackViewModel: _allResource);
    }
 
    private void SetStatus(ConsoleLogsViewModel viewModel, string statusName)
    {
        Logger.LogDebug("Setting status to '{StatusName}'.", statusName);
        viewModel.Status = Loc[statusName];
    }
 
    protected override async Task OnParametersSetAsync()
    {
        Logger.LogDebug("Initializing console logs view model.");
        if (await this.InitializeViewModelAsync())
        {
            return;
        }
 
        UpdateMenuButtons();
 
        // Determine if we're subscribing to "All" resources or a specific resource
        var isAllSelected = IsAllSelected();
        var selectedResourceName = PageViewModel.SelectedResource.Id?.InstanceId;
 
        // Check if subscription needs to change
        var needsNewSubscription = false;
 
        if (isAllSelected != _isSubscribedToAll)
        {
            Logger.LogDebug("Switching to or from 'All' mode");
            needsNewSubscription = true;
        }
        else if (!string.IsNullOrEmpty(selectedResourceName) && !_consoleLogsSubscriptions.ContainsKey(selectedResourceName))
        {
            Logger.LogDebug("Switching to different single resource: {ResourceName}", selectedResourceName);
            needsNewSubscription = true;
        }
 
        if (needsNewSubscription)
        {
            await SubscribeAsync(isAllSelected, selectedResourceName, resetView: true);
        }
 
        UpdateTelemetryProperties();
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        // After a layout transition (e.g. mobile→desktop viewport flip moves
        // the toolbar back inline from the mobile filter dialog) the toolbar
        // RenderFragment re-evaluates against the page's current state. If
        // _terminalToolbarState was cleared during the transition but the
        // JS terminal is still alive in MainSection, the toolbar would not
        // re-render until the JS side happens to push a new snapshot — and
        // JS suppresses no-op pushes via change detection. Ask JS to re-push
        // so the toolbar controls reappear.
        if (_selectedResourceHasTerminal &&
            _terminalViewRef is { } terminalView &&
            _terminalToolbarState is null)
        {
            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, bool resetView)
    {
        Logger.LogDebug("Subscription change needed. IsAllSelected: {IsAllSelected}, SelectedResource: {SelectedResource}", isAllSelected, selectedResourceName);
 
        // Detect whether the selected resource has terminal support
        _selectedResourceHasTerminal = false;
        _terminalResourceName = null;
        _terminalReplicaIndex = 0;
        // Drop any prior terminal's toolbar state so we don't briefly render
        // the wrong badge/dims/dropdown for the new resource while the JS
        // terminal is initializing and pushing its first snapshot.
        _terminalToolbarState = null;
 
        // 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;
        }
 
        var selectedResource = selectedResourceName is not null
            ? _resourceByName.GetValueOrDefault(selectedResourceName)
            : null;
 
        if (!DashboardClient.IsReadOnly &&
            !isAllSelected && selectedResource is not null &&
            selectedResource.HasTerminal() &&
            selectedResource.TryGetTerminalReplicaInfo(out var replicaIndex, out _))
        {
            _selectedResourceHasTerminal = true;
            _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
            // 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
        await CancelAllSubscriptionsAsync();
 
        // Clear log entries for new subscription
        Logger.LogDebug("Creating new log entries collection.");
        lock (_updateLogsLock)
        {
            _logEntries.Clear(keepActivePauseEntries: false);
        }
        ResetNoLogsMessage();
 
        _consoleLogsWereLoaded = !DashboardClient.IsReadOnly || WereConsoleLogsLoaded(isAllSelected, selectedResource);
 
        await InvokeAsync(_logViewerRef.SafeRefreshDataAsync);
 
        if (isAllSelected)
        {
            // Subscribe to all available resources
            _isSubscribedToAll = true;
            await SubscribeToAllResourcesAsync();
        }
        else if (selectedResource is not null)
        {
            // Subscribe to single resource
            _isSubscribedToAll = false;
            await SubscribeToSingleResourceAsync(selectedResource);
        }
        else
        {
            Logger.LogDebug("Unexpected state. Unknown resource '{ResourceName}' selected.", selectedResourceName);
        }
 
        if (!_consoleLogsSubscriptions.IsEmpty)
        {
            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 WereConsoleLogsLoaded(bool isAllSelected, ResourceViewModel? selectedResource)
    {
        if (isAllSelected)
        {
            return _resourceByName.Values
                .Where(resource => !resource.IsResourceHidden(_showHiddenResources))
                .Any(resource => resource.ConsoleLogsLoaded);
        }
 
        return selectedResource?.ConsoleLogsLoaded == true;
    }
 
    private string GetNoLogsMessage() => _consoleLogsWereLoaded
        ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoLogsFound)]
        : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNotCapturedForRun)];
 
    private bool IsAllSelected()
    {
        return PageViewModel?.SelectedResource is not null && PageViewModel.SelectedResource == _allResource;
    }
 
    private void UpdateMenuButtons()
    {
        _highlightedCommands.Clear();
        _logsMenuItems.Clear();
        _resourceMenuItems.Clear();
 
        var selectedResource = GetSelectedResource();
 
        // View toggle (Console / Terminal): only meaningful for terminal-
        // enabled resources; keeps the menu identical to today for the common
        // no-terminal case.
        if (_selectedResourceHasTerminal)
        {
            // Model the two view options as checkable menu items so the picker
            // exposes the current selection to assistive technology, not just to
            // sighted users. FluentMenuItem only emits role="menuitemcheckbox" and
            // the reflected aria-checked state (which screen readers announce) when
            // the item carries a checkable Role; a leading icon alone conveys the
            // selection visually but is silent to a screen reader. The checkbox role
            // also renders a checkmark indicator on the checked item.
            _logsMenuItems.Add(new()
            {
                OnClick = () => HandleViewChangedAsync(nameof(ConsoleLogsView.Console)),
                Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewConsoleOption)],
                Role = MenuItemRole.MenuItemCheckbox,
                Checked = _activeView == ConsoleLogsView.Console,
            });
 
            _logsMenuItems.Add(new()
            {
                OnClick = () => HandleViewChangedAsync(nameof(ConsoleLogsView.Terminal)),
                Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsViewTerminalOption)],
                Role = MenuItemRole.MenuItemCheckbox,
                Checked = _activeView == ConsoleLogsView.Terminal,
            });
 
            _logsMenuItems.Add(new()
            {
                IsDivider = true
            });
        }
 
        if (_activeView == ConsoleLogsView.Terminal)
        {
            // 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<MenuButtonItem>();
                foreach (var preset in _terminalSizePresets)
                {
                    var value = preset.Value;
                    nested.Add(new()
                    {
                        OnClick = () => TerminalSizeChangedAsync(value),
                        Text = preset.Label,
                        IsDisabled = !sizeSelectEnabled,
                    });
                }
 
                _logsMenuItems.Add(new()
                {
                    Text = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSize)],
                    Icon = new Icons.Regular.Size16.ArrowExpand(),
                    NestedMenuItems = nested,
                });
            }
        }
        else
        {
            // 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()
            {
                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())
            {
                CommonMenuItems.AddToggleHiddenResourcesMenuItem(
                    _logsMenuItems,
                    ControlsStringsLoc,
                    _showHiddenResources,
                    _resourceByName.Values,
                    SessionStorage,
                    EventCallback.Factory.Create<bool>(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)
        {
            if (ViewportInformation.IsDesktop)
            {
                _highlightedCommands.AddRange(selectedResource.Commands.Where(c => c.IsHighlighted && c.State != CommandViewModelState.Hidden).Take(DashboardUIHelpers.MaxHighlightedCommands));
            }
 
            ResourceMenuBuilder.AddMenuItems(
                _resourceMenuItems,
                selectedResource,
                _resourceByName,
                EventCallback.Factory.Create(this, () =>
                {
                    NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(resource: selectedResource.Name));
                    return Task.CompletedTask;
                }),
                EventCallback.Factory.Create<CommandViewModel>(this, ExecuteResourceCommandAsync),
                (resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name),
                showViewDetails: true,
                showConsoleLogsItem: false,
                showUrls: true);
        }
    }
 
    private ResourceViewModel? GetSelectedResource()
    {
        var name = PageViewModel?.SelectedResource.Id?.InstanceId;
        if (name == null)
        {
            return null;
        }
        _resourceByName.TryGetValue(name, out var resource);
        return resource;
    }
 
    private async Task ToggleTimestampAsync(bool showTimestamp, bool isTimestampUtc)
    {
        _showTimestamp = showTimestamp;
        _isTimestampUtc = isTimestampUtc;
        await UpdateConsoleLogSettingsAsync();
    }
 
    private async Task ToggleWrapLogsAsync(bool noWrapLogs)
    {
        _noWrapLogs = noWrapLogs;
        await UpdateConsoleLogSettingsAsync();
    }
 
    private async Task UpdateConsoleLogSettingsAsync()
    {
        await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.ConsoleLogConsoleSettings, new ConsoleLogConsoleSettings(_showTimestamp, _isTimestampUtc, _noWrapLogs));
        UpdateMenuButtons();
        StateHasChanged();
        await this.RefreshIfMobileAsync(_contentLayout);
    }
 
    private async Task ExecuteResourceCommandAsync(CommandViewModel command)
    {
        var selectedResource = GetSelectedResource();
        if (selectedResource is null)
        {
            Logger.LogWarning("No resource selected for command execution.");
            return;
        }
 
        await DashboardCommandExecutor.ExecuteAsync(selectedResource, command, GetResourceName);
    }
 
    private async Task CancelAllSubscriptionsAsync()
    {
        if (_consoleLogsSubscriptions.IsEmpty)
        {
            return;
        }
 
        var subscriptionsToCancel = _consoleLogsSubscriptions.Values.ToList();
        _consoleLogsSubscriptions.Clear();
 
        foreach (var subscription in subscriptionsToCancel)
        {
            subscription.Cancel();
        }
 
        // Wait for all subscriptions to finish
        var tasks = subscriptionsToCancel
            .Where(s => s.SubscriptionTask is not null)
            .Select(s => TaskHelpers.WaitIgnoreCancelAsync(s.SubscriptionTask))
            .ToArray();
 
        if (tasks.Length > 0)
        {
            await Task.WhenAll(tasks);
        }
    }
 
    private async Task SubscribeToAllResourcesAsync()
    {
        var availableResources = _resourceByName.Values
            .Where(r => !r.IsResourceHidden(_showHiddenResources))
            .ToList();
 
        Logger.LogDebug("Subscribing to {ResourceCount} resources for 'All' view.", availableResources.Count);
 
        if (availableResources.Count == 0)
        {
            Logger.LogDebug("No resources available to subscribe to for 'All' view - will show empty logs.");
            SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsLoadingResources));
            await InvokeAsync(StateHasChanged);
            return;
        }
 
        // Set status to indicate we're starting to watch logs
        SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWatchingLogs));
        await InvokeAsync(StateHasChanged);
 
        foreach (var resource in availableResources)
        {
            await SubscribeToSingleResourceAsync(resource);
        }
 
        Logger.LogDebug("Successfully created {SubscriptionCount} subscriptions for 'All' view.", _consoleLogsSubscriptions.Count);
    }
 
    private Task SubscribeToSingleResourceAsync(ResourceViewModel resource)
    {
        var resourceName = resource.Name;
 
        if (_consoleLogsSubscriptions.ContainsKey(resourceName))
        {
            Logger.LogDebug("Already subscribed to resource {ResourceName}.", resourceName);
            return Task.CompletedTask;
        }
 
        var subscription = new ConsoleLogsSubscription(resource, Logger);
        Logger.LogDebug("Creating new subscription {SubscriptionId} for resource {ResourceName}.", subscription.SubscriptionId, resourceName);
 
        // Add the subscription to the dictionary before starting the task
        if (_consoleLogsSubscriptions.TryAdd(resourceName, subscription))
        {
            LoadLogsForResource(subscription);
            Logger.LogDebug("Started log subscription task for resource {ResourceName}.", resourceName);
        }
        else
        {
            Logger.LogWarning("Failed to add subscription for resource {ResourceName} - may already exist.", resourceName);
        }
 
        return Task.CompletedTask;
    }
 
    private void StartNoLogsMessageDelay()
    {
        ResetNoLogsMessage();
 
        _showNoLogsMessageCts = new();
        _showNoLogsMessageDelayTask = ShowNoLogsMessageAfterDelayAsync(_showNoLogsMessageCts.Token);
    }
 
    private void ResetNoLogsMessage()
    {
        _showNoLogsMessageCts?.Cancel();
        _showNoLogsMessage = false;
    }
 
    private async Task ShowNoLogsMessageAfterDelayAsync(CancellationToken cancellationToken)
    {
        try
        {
            await Task.Delay(s_noLogsMessageDelay, cancellationToken);
 
            await InvokeAsync(() =>
            {
                if (!cancellationToken.IsCancellationRequested)
                {
                    var hasNoLogEntries = false;
                    lock (_updateLogsLock)
                    {
                        hasNoLogEntries = _logEntries.EntriesCount == 0;
                    }
 
                    _showNoLogsMessage = hasNoLogEntries;
                    StateHasChanged();
                }
            });
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception ex)
        {
            Logger.LogDebug(ex, "Failed to show the no logs message.");
        }
    }
 
    private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName);
 
    internal static ImmutableList<SelectViewModel<ResourceTypeDetails>> GetConsoleLogResourceSelectViewModels(
        ConcurrentDictionary<string, ResourceViewModel> resourcesByName,
        SelectViewModel<ResourceTypeDetails> allResourceViewModel,
        string resourceUnknownStateText,
        bool showHiddenResources,
        out SelectViewModel<ResourceTypeDetails>? optionToSelect)
    {
        var builder = ImmutableList.CreateBuilder<SelectViewModel<ResourceTypeDetails>>();
 
        foreach (var grouping in resourcesByName
            .Where(r => !r.Value.IsResourceHidden(showHiddenResources))
            .OrderBy(c => c.Value, ResourceViewModelNameComparer.Instance)
            .GroupBy(r => r.Value.DisplayName, StringComparers.ResourceName))
        {
            string resourceName;
 
            if (grouping.Count() > 1)
            {
                resourceName = grouping.Key;
 
                builder.Add(new SelectViewModel<ResourceTypeDetails>
                {
                    Id = ResourceTypeDetails.CreateResourceGrouping(resourceName, true),
                    Name = resourceName
                });
            }
            else
            {
                resourceName = grouping.First().Value.DisplayName;
            }
 
            foreach (var resource in grouping.Select(g => g.Value).OrderBy(r => r, ResourceViewModelNameComparer.Instance))
            {
                builder.Add(ToOption(resource, grouping.Count() > 1, resourceName));
            }
        }
 
        // If there are multiple resources, add "All" option.
        // If there is one resource then it is automatically selected.
        // If there are no resources, default to "All" (which will show no logs but is ready for when resources appear).
        if (builder.Count == 1)
        {
            optionToSelect = builder.Single();
        }
        else
        {
            builder.Insert(0, allResourceViewModel);
            optionToSelect = null;
        }
 
        return builder.ToImmutableList();
 
        SelectViewModel<ResourceTypeDetails> ToOption(ResourceViewModel resource, bool isReplica, string resourceName)
        {
            var id = isReplica
                ? ResourceTypeDetails.CreateReplicaInstance(resource.Name, resourceName)
                : ResourceTypeDetails.CreateSingleton(resource.Name, resourceName);
 
            return new SelectViewModel<ResourceTypeDetails>
            {
                Id = id,
                Name = GetDisplayText()
            };
 
            string GetDisplayText()
            {
                var resourceName = ResourceViewModel.GetResourceName(resource, resourcesByName);
 
                if (resource.HasNoState())
                {
                    return $"{resourceName} ({resourceUnknownStateText})";
                }
 
                if (resource.IsRunningState())
                {
                    return resourceName;
                }
 
                return $"{resourceName} ({resource.State})";
            }
        }
    }
 
    private void UpdateResourcesList()
    {
        _resources = GetConsoleLogResourceSelectViewModels(_resourceByName, _allResource, Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsUnknownState)], _showHiddenResources, out var optionToSelect);
 
        if (optionToSelect is not null)
        {
            PageViewModel.SelectedResource = optionToSelect;
        }
    }
 
    private void LoadLogsForResource(ConsoleLogsSubscription subscription)
    {
        Logger.LogDebug("Starting log subscription {SubscriptionId}.", subscription.SubscriptionId);
        var consoleLogsTask = Task.Run(async () =>
        {
            subscription.CancellationToken.ThrowIfCancellationRequested();
 
            Logger.LogDebug("Subscribing to console logs with subscription {SubscriptionId} to resource {ResourceName}.", subscription.SubscriptionId, subscription.Resource.Name);
 
            var logSubscription = DashboardClient.SubscribeConsoleLogs(subscription.Resource.Name, subscription.CancellationToken);
 
            // For "All" subscriptions, only update status once when starting
            if (_isSubscribedToAll && _consoleLogsSubscriptions.Count == 1)
            {
                SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWatchingLogs));
                await InvokeAsync(StateHasChanged);
            }
            // For single resource subscriptions, always update status
            else if (!_isSubscribedToAll)
            {
                SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsWatchingLogs));
                await InvokeAsync(StateHasChanged);
            }
 
            var hasError = false;
            try
            {
                lock (_updateLogsLock)
                {
                    var pauseIntervals = PauseManager.ConsoleLogPauseIntervals;
                    Logger.LogDebug("Adding {PauseIntervalsCount} pause intervals on initial logs load.", pauseIntervals.Length);
 
                    foreach (var priorPause in pauseIntervals)
                    {
                        _logEntryChannel.Writer.TryWrite(new LogEntryToWrite(subscription.Resource.Name, LogEntry.CreatePause(GetResourceName(subscription.Resource), priorPause.Start, priorPause.End), LineNumber: null));
                    }
                }
 
                var resourcePrefix = ResourceViewModel.GetResourceName(subscription.Resource, _resourceByName);
 
                var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true);
                await foreach (var batch in logSubscription.ConfigureAwait(false))
                {
                    subscription.CancellationToken.ThrowIfCancellationRequested();
 
                    if (batch.Count is 0)
                    {
                        continue;
                    }
 
                    foreach (var (lineNumber, content, isErrorOutput) in batch)
                    {
                        var logEntry = logParser.CreateLogEntry(content, isErrorOutput, resourcePrefix);
 
                        _logEntryChannel.Writer.TryWrite(new LogEntryToWrite(subscription.Resource.Name, logEntry, lineNumber));
                    }
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // If the subscription is being canceled then error could be transient from cancellation. Ignore errors during cancellation.
                if (!subscription.CancellationToken.IsCancellationRequested)
                {
                    hasError = true;
                    Logger.LogError(ex, "Error watching logs for resource {ResourceName}.", subscription.Resource.Name);
 
                    // For single resource subscriptions or first subscription in "All" mode, update status
                    if (!_isSubscribedToAll)
                    {
                        SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsErrorWatchingLogs));
                        await InvokeAsync(StateHasChanged);
                    }
                }
            }
            finally
            {
                // Remove the subscription from tracking
                _consoleLogsSubscriptions.TryRemove(subscription.Resource.Name, out _);
 
                // If the subscription is being canceled then a new one could be starting.
                // Don't set the status when finishing because overwrite the status from the new subscription.
                // Also don't overwrite error status if an error occurred.
                if (!subscription.CancellationToken.IsCancellationRequested && !_isSubscribedToAll && !hasError)
                {
                    SetStatus(PageViewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsFinishedWatchingLogs));
                    await InvokeAsync(StateHasChanged);
                }
 
                Logger.LogDebug("Subscription {SubscriptionId} finished watching logs for resource {ResourceName}. Cancel duration: {Duration}", subscription.SubscriptionId, subscription.Resource.Name, subscription.GetCancelElapsedTime());
            }
        });
 
        subscription.SubscriptionTask = consoleLogsTask;
    }
 
    private async Task HandleSelectedOptionChangedAsync()
    {
        await this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false);
    }
 
    private async Task OnResourceChanged(ResourceViewModelChangeType changeType, ResourceViewModel resource)
    {
        if (changeType == ResourceViewModelChangeType.Upsert)
        {
            _resourceByName[resource.Name] = resource;
            UpdateResourcesList();
 
            // If we're subscribed to all resources and this is a new resource, subscribe to it
            if (_isSubscribedToAll && !_consoleLogsSubscriptions.ContainsKey(resource.Name) &&
                !resource.IsResourceHidden(_showHiddenResources))
            {
                await SubscribeToSingleResourceAsync(resource);
            }
        }
        else if (changeType == ResourceViewModelChangeType.Delete)
        {
            var removed = _resourceByName.TryRemove(resource.Name, out _);
            Debug.Assert(removed, "Cannot remove unknown resource.");
 
            // Cancel subscription for the deleted resource
            if (_consoleLogsSubscriptions.TryRemove(resource.Name, out var subscription))
            {
                // Fire and forget
                _ = Task.Run(async () =>
                {
                    subscription.Cancel();
                    if (subscription.SubscriptionTask is { } task)
                    {
                        await task;
                    }
                });
            }
 
            if (string.Equals(PageViewModel.SelectedResource.Id?.InstanceId, resource.Name, StringComparisons.ResourceName))
            {
                // The selected resource was deleted
                PageViewModel.SelectedResource = _allResource;
                await HandleSelectedOptionChangedAsync();
            }
 
            UpdateResourcesList();
        }
    }
 
    private async Task DownloadLogsAsync()
    {
        // Write all log entry content to a stream as UTF8 chars. Strip control sequences from log lines.
        var stream = new MemoryStream();
        lock (_updateLogsLock)
        {
            LogEntrySerializer.WriteLogEntriesToStream(_logEntries.GetEntries(), stream);
        }
        stream.Seek(0, SeekOrigin.Begin);
 
        await JS.DownloadFileAsync(GetFileName(), stream);
    }
 
    private string GetFileName()
    {
        var fileNamePrefix = _isSubscribedToAll
            ? "AllResources"
            : string.Join("_", PageViewModel.SelectedResource.Id!.InstanceId!.Split(Path.GetInvalidFileNameChars()));
 
        return $"{fileNamePrefix}-{TimeProvider.GetLocalNow().ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture)}.txt";
    }
 
    private async Task ClearConsoleLogs(ResourceKey? key)
    {
        var now = TimeProvider.GetUtcNow().UtcDateTime;
        var resourceNames = key is null
            ? DataSource.ResourceRepository.GetResources().Select(resource => resource.Name).ToList()
            : [key.Value.ToString()];
 
        await DataSource.ResourceRepository.ClearConsoleLogsAsync(resourceNames, now);
 
        var newFilters = key is null
            ? ConsoleLogsFilters.CreateClearAll(now)
            : ConsoleLogsManager.Filters.WithResourceCleared(key.Value.ToString(), now);
 
        // Save filters to session storage so they're persisted when navigating to and from the console logs page.
        // This makes remove behavior persistent which matches removing telemetry.
        await ConsoleLogsManager.UpdateFiltersAsync(newFilters);
    }
 
    private string? PauseText => PauseManager.ConsoleLogPauseIntervals.LastOrDefault() is { End: null } pause
        ? string.Format(
            CultureInfo.CurrentCulture,
            Loc[nameof(Dashboard.Resources.ConsoleLogs.PauseInProgressText)],
            FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, pause.Start))
        : null;
 
    private void OnPausedChanged(bool isPaused)
    {
        Logger.LogDebug("Console logs paused new value: {IsPausedNewValue}", isPaused);
 
        var timestamp = DateTime.UtcNow;
        PauseManager.SetConsoleLogsPaused(isPaused, timestamp);
 
        if (PageViewModel.SelectedResource != null)
        {
            lock (_updateLogsLock)
            {
                if (isPaused)
                {
                    foreach (var subscription in _consoleLogsSubscriptions.Values)
                    {
                        Logger.LogDebug("Inserting new pause log entry for {Resource} starting at {StartTimestamp}.", subscription.Resource.Name, timestamp);
                        _logEntryChannel.Writer.TryWrite(new LogEntryToWrite(subscription.Resource.Name, LogEntry.CreatePause(GetResourceName(subscription.Resource), timestamp), LineNumber: null));
                    }
                }
                else
                {
                    var entries = _logEntries.GetEntries();
                    foreach (var subscription in _consoleLogsSubscriptions.Values)
                    {
                        var resourcePrefix = GetResourceName(subscription.Resource);
                        var lastResourceEntry = entries.LastOrDefault(e => e.ResourcePrefix == resourcePrefix);
 
                        if (lastResourceEntry?.Pause is { } pause)
                        {
                            Logger.LogDebug("Updating pause log entry for {Resource} starting at {StartTimestamp} with end of {EndTimestamp}.", subscription.Resource.Name, pause.StartTime, timestamp);
                            pause.EndTime = timestamp;
                        }
                    }
                }
            }
 
            _ = InvokeAsync(_logViewerRef.SafeRefreshDataAsync);
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        ResetNoLogsMessage();
        _showNoLogsMessageCts?.Cancel();
        await TaskHelpers.WaitIgnoreCancelAsync(_showNoLogsMessageDelayTask);
 
        _consoleLogsFiltersChangedSubscription?.Dispose();
 
        _resourceSubscriptionCts.Cancel();
        _resourceSubscriptionCts.Dispose();
        await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask);
        await TaskHelpers.WaitIgnoreCancelAsync(_logEntryChannelReaderTask);
 
        await CancelAllSubscriptionsAsync();
        TelemetryContext.Dispose();
    }
 
    public class ConsoleLogsViewModel
    {
        public required string Status { get; set; }
        public required SelectViewModel<ResourceTypeDetails> SelectedResource { get; set; }
    }
 
    public record ConsoleLogsPageState(string? SelectedResource);
 
    public record ConsoleLogConsoleSettings(bool ShowTimestamp, bool IsTimestampUtc, bool NoWrapLogs);
 
    public Task UpdateViewModelFromQueryAsync(ConsoleLogsViewModel viewModel)
    {
        if (_resources is not null)
        {
            if (ResourceName is not null)
            {
                viewModel.SelectedResource = GetSelectedOption();
                viewModel.Status ??= Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsLogsNotYetAvailable)];
                return Task.CompletedTask;
            }
            else if (TryGetSingleResource() is { } r)
            {
                // If there is no resource selected and there is only one resource available, select it.
                viewModel.SelectedResource = _resources.GetResource(Logger, r.Name, canSelectGrouping: false, fallbackViewModel: _allResource);
                return this.AfterViewModelChangedAsync(_contentLayout, waitToApplyMobileChange: false);
            }
        }
 
        viewModel.SelectedResource = _allResource;
        SetStatus(viewModel, nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsLoadingResources));
        return Task.CompletedTask;
 
        ResourceViewModel? TryGetSingleResource()
        {
            var actualResources = _resourceByName.Values.Where(r => !r.IsResourceHidden(showHiddenResources: _showHiddenResources)).ToList();
            return actualResources.Count == 1 ? actualResources[0] : null;
        }
    }
 
    public string GetUrlFromSerializableViewModel(ConsoleLogsPageState serializable)
    {
        return DashboardUrls.ConsoleLogsUrl(serializable.SelectedResource);
    }
 
    public ConsoleLogsPageState ConvertViewModelToSerializable()
    {
        var selectedResourceName = GetSelectedResource() is { } selectedResource
            ? GetResourceName(selectedResource)
            : null;
        return new ConsoleLogsPageState(selectedResourceName);
    }
 
    // --- Terminal toolbar wiring -----------------------------------------
    //
    // The TerminalView component pushes a TerminalToolbarState snapshot up
    // here whenever the underlying xterm/HMP1 state changes (role flips,
    // resize, font change). Those snapshots drive the page-level toolbar
    // that replaces the in-frame chrome the terminal used to render itself.
    // JS remains the source of truth for terminal state; this layer just
    // mirrors the latest snapshot and routes user actions back to JS via
    // the TerminalView public methods.
    private const int TerminalFontStep = 1;
    private const int TerminalFontMin = 4;
    private const int TerminalFontMax = 72;
 
    private async Task OnTerminalToolbarStateChangedAsync(Controls.TerminalToolbarState state)
    {
        _terminalToolbarState = state;
 
        // First snapshot after init — fetch the size preset list once so
        // the dropdown stays in sync with whatever JS knows how to handle.
        if (_terminalSizePresets.Count == 0 && _terminalViewRef is not null)
        {
            // The JS side ships labels as English string literals (it has no
            // localization stack of its own). Numeric labels like "80×24" are
            // language-neutral and pass through unchanged, but "Auto" is an
            // English word and must come from the dashboard's .resx so it
            // matches the rest of the terminal toolbar in every supported
            // culture. Apply the localized label here, where we still have
            // access to IStringLocalizer<Resources.ConsoleLogs>, before
            // handing the list to FluentSelect.
            var presets = await _terminalViewRef.GetSizePresetsAsync();
            _terminalSizePresets = presets
                .Select(p => p.Value == "auto"
                    ? p with { Label = Loc[nameof(Dashboard.Resources.ConsoleLogs.TerminalToolbarGridSizeAuto)] }
                    : p)
                .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<ConsoleLogsView>(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;
    }
 
    // 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);
    internal IReadOnlyList<MenuButtonItem> LogsMenuItemsForTest => _logsMenuItems;
    // Lets a test wait for a background resource-subscription update (a state
    // transition delivered via the resource channel) to actually be applied
    // before asserting, so a "view is unchanged" assertion can't pass simply
    // because the update hasn't been processed yet.
    internal ResourceViewModel? GetResourceSnapshotForTest(string resourceName) =>
        _resourceByName.TryGetValue(resourceName, out var resource) ? resource : null;
 
    private Task TerminalFontMinusAsync()
    {
        if (_terminalToolbarState is not { } s || _terminalViewRef is null)
        {
            return Task.CompletedTask;
        }
        return _terminalViewRef.SetFontSizeAsync(Math.Max(TerminalFontMin, s.FontPx - TerminalFontStep));
    }
 
    private Task TerminalFontPlusAsync()
    {
        if (_terminalToolbarState is not { } s || _terminalViewRef is null)
        {
            return Task.CompletedTask;
        }
        return _terminalViewRef.SetFontSizeAsync(Math.Min(TerminalFontMax, s.FontPx + TerminalFontStep));
    }
 
    private Task TerminalSizeChangedAsync(string? newKey)
    {
        if (newKey is null || _terminalViewRef is null)
        {
            return Task.CompletedTask;
        }
        return _terminalViewRef.SetSizeModeAsync(newKey);
    }
 
    // IComponentWithTelemetry impl
    public ComponentTelemetryContext TelemetryContext { get; } = new(ComponentType.Page, TelemetryComponentIds.ConsoleLogs);
 
    public void UpdateTelemetryProperties()
    {
        TelemetryContext.UpdateTelemetryProperties([
            new ComponentTelemetryProperty(TelemetryPropertyKeys.ConsoleLogsShowTimestamp, new AspireTelemetryProperty(_showTimestamp, AspireTelemetryPropertyType.UserSetting))
        ], Logger);
    }
 
    /// <summary>
    /// The two MainSection contents the <see cref="ConsoleLogs"/> page can show
    /// for a resource that has <c>WithTerminal()</c> applied. Non-terminal
    /// resources implicitly always show <see cref="Console"/>.
    /// </summary>
    public enum ConsoleLogsView
    {
        /// <summary>The resource's standard log stream (LogViewer).</summary>
        Console,
        /// <summary>The interactive xterm.js terminal (TerminalView).</summary>
        Terminal,
    }
}