| File: Components\Controls\LogViewer.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.Globalization; using System.Text; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components; /// <summary> /// A log viewing UI component that shows a live view of a log, with syntax highlighting and automatic scrolling. /// </summary> public sealed partial class LogViewer { private const string ScrollContainerId = "logScrollContainer"; private static readonly MarkupString s_spaceMarkup = new MarkupString(" "); private LogEntries? _logEntries; private bool _logsChanged; private IList<LogEntry>? _visibleEntriesCache; private string? _appliedFilterText; private bool _appliedShowTimestamp; private bool _appliedShowResourcePrefix; private bool _appliedIsTimestampUtc; private bool _visibleEntriesChanged; [Inject] public required BrowserTimeProvider TimeProvider { get; init; } [Inject] public required DimensionManager DimensionManager { get; init; } [Inject] public required ILogger<LogViewer> Logger { get; init; } [Parameter] public LogEntries? LogEntries { get; set; } = null!; [Parameter] public bool ShowTimestamp { get; set; } [Parameter] public bool ShowResourcePrefix { get; set; } [Parameter] public bool IsTimestampUtc { get; set; } [Parameter] public bool NoWrapLogs { get; set; } [Parameter] public bool ShowNoLogsMessage { get; set; } [Parameter] public string? NoLogsMessage { get; set; } [Parameter] public string? FilterText { get; set; } private Virtualize<LogEntry>? VirtualizeRef { get => field; set { field = value; // Set max item count when the Virtualize component is set. if (field != null) { VirtualizeHelper<LogEntry>.TrySetMaxItemCount(field, 10_000); } } } public async Task RefreshDataAsync() { // Entries may have been appended or evicted (circular buffer) since the last render, so drop // the cached filtered view before Virtualize re-queries through GetItems. _visibleEntriesCache = null; await RefreshVirtualizeAsync(); } private async Task RefreshVirtualizeAsync() { if (VirtualizeRef == null) { return; } await VirtualizeRef.RefreshDataAsync(); StateHasChanged(); } protected override void OnParametersSet() { if (_logEntries != LogEntries) { Logger.LogDebug("Log entries changed."); _logsChanged = true; _logEntries = LogEntries; _visibleEntriesCache = null; } var filterChanged = !string.Equals(_appliedFilterText, FilterText, StringComparison.Ordinal); var searchableFieldsChanged = _appliedShowTimestamp != ShowTimestamp || _appliedShowResourcePrefix != ShowResourcePrefix || _appliedIsTimestampUtc != IsTimestampUtc; _appliedFilterText = FilterText; _appliedShowTimestamp = ShowTimestamp; _appliedShowResourcePrefix = ShowResourcePrefix; _appliedIsTimestampUtc = IsTimestampUtc; if (filterChanged || (searchableFieldsChanged && !string.IsNullOrWhiteSpace(FilterText))) { _visibleEntriesCache = null; // Virtualize only re-queries GetItems on an explicit RefreshDataAsync call. // We can't call it here (OnParametersSet) because Virtualize.RefreshDataAsync() // triggers a child-component re-render mid-lifecycle, which creates re-entrant // rendering in Blazor Server. Additionally, OnParametersSetAsync would cause a // double-render (sync portion renders stale items, then re-renders after await). // Deferring to OnAfterRenderAsync guarantees the full component tree has rendered // with the new state, and the cache is already warm from the razor markup's call // to GetVisibleEntries() (for the "no logs match" message). _visibleEntriesChanged = true; } base.OnParametersSet(); } private ValueTask<ItemsProviderResult<LogEntry>> GetItems(ItemsProviderRequest r) { var entries = GetVisibleEntries(); return ValueTask.FromResult(new ItemsProviderResult<LogEntry>(entries.Skip(r.StartIndex).Take(r.Count), entries.Count)); } private IList<LogEntry> GetVisibleEntries() { if (_visibleEntriesCache is { } cached) { return cached; } var entries = _logEntries?.GetEntries(); if (entries is null) { return _visibleEntriesCache = Array.Empty<LogEntry>(); } var filterText = FilterText; if (string.IsNullOrWhiteSpace(filterText)) { return _visibleEntriesCache = entries; } return _visibleEntriesCache = entries.Where(e => MatchesFilter(e, filterText)).ToList(); } private bool MatchesFilter(LogEntry entry, string filterText) { if (entry.Type is LogEntryType.Pause) { return false; } return GetSearchableText(entry).Contains(filterText, StringComparison.OrdinalIgnoreCase); } private string GetSearchableText(LogEntry entry) { var builder = new StringBuilder(); // Keep this in sync with the row markup in LogViewer.razor. Filtering should match only text // users can see: optional/display-formatted timestamps, optional resource prefixes, the stderr // badge, and the ANSI-stripped log message. RawContent is not enough because it contains hidden // ISO timestamps and raw ANSI escape sequences. if (ShowTimestamp && entry.Timestamp is { } timestamp) { AppendSearchablePart(builder, GetDisplayTimestamp(timestamp)); } if (ShowResourcePrefix && entry.ResourcePrefix is { } resourcePrefix) { AppendSearchablePart(builder, resourcePrefix); } if (entry.Type is LogEntryType.Error) { AppendSearchablePart(builder, "stderr"); } if (entry.GetStrippedLogContent() is { } content) { AppendSearchablePart(builder, content); } return builder.ToString(); } private static void AppendSearchablePart(StringBuilder builder, string value) { if (string.IsNullOrEmpty(value)) { return; } if (builder.Length > 0) { builder.Append(' '); } builder.Append(value); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (_logsChanged) { await JS.InvokeVoidAsync("resetContinuousScrollPosition"); _logsChanged = false; } if (_visibleEntriesChanged) { _visibleEntriesChanged = false; // The filtered view was already rebuilt for the new parameters during this render pass // (GetVisibleEntries runs from the markup to decide the "no logs match" message), so just // re-query Virtualize. Calling the public RefreshDataAsync here would null the cache and // force a second full scan of the log buffer. await RefreshVirtualizeAsync(); } if (firstRender) { Logger.LogDebug("Initializing log viewer."); await JS.InvokeVoidAsync("initializeContinuousScroll"); // Focus the scroll container without showing the focus ring. The container is a large // content area where a visible focus indicator would be visually noisy on initial load. await JS.InvokeVoidAsync("focusElement", ScrollContainerId, true); DimensionManager.OnViewportInformationChanged += OnBrowserResize; } } private void OnBrowserResize(object? o, EventArgs args) { InvokeAsync(async () => { await JS.InvokeVoidAsync("resetContinuousScrollPosition"); await JS.InvokeVoidAsync("initializeContinuousScroll"); }); } private string GetDisplayTimestamp(DateTimeOffset timestamp) { return IsTimestampUtc ? timestamp.UtcDateTime.ToString(KnownFormats.ConsoleLogsUITimestampUtcFormat, CultureInfo.InvariantCulture) : TimeProvider.ToLocal(timestamp).ToString(KnownFormats.ConsoleLogsUITimestampLocalFormat, CultureInfo.InvariantCulture); } private string GetLogContainerClass() { return $"log-container console-container {(NoWrapLogs ? "wrap-log-container" : null)}"; } public ValueTask DisposeAsync() { Logger.LogDebug("Disposing log viewer."); DimensionManager.OnViewportInformationChanged -= OnBrowserResize; return ValueTask.CompletedTask; } }