diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index ebdaab2921..e37b5c0973 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Threading.Channels; using Stress.ApiService; var builder = WebApplication.CreateBuilder(args); @@ -25,4 +27,48 @@ return "Big trace created"; }); +app.MapGet("/many-logs", (ILoggerFactory loggerFactory, CancellationToken cancellationToken) => +{ + var channel = Channel.CreateUnbounded(); + var logger = loggerFactory.CreateLogger("ManyLogs"); + + cancellationToken.Register(() => + { + logger.LogInformation("Writing logs canceled."); + }); + + // Write logs for 1 minute. + _ = Task.Run(async () => + { + var stopwatch = Stopwatch.StartNew(); + var logCount = 0; + while (stopwatch.Elapsed < TimeSpan.FromMinutes(1)) + { + cancellationToken.ThrowIfCancellationRequested(); + + logCount++; + logger.LogInformation("This is log message {LogCount}.", logCount); + + if (logCount % 100 == 0) + { + channel.Writer.TryWrite($"Logged {logCount} messages."); + } + + await Task.Delay(5, cancellationToken); + } + + channel.Writer.Complete(); + }, cancellationToken); + + return WriteOutput(); + + async IAsyncEnumerable WriteOutput() + { + await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return message; + } + } +}); + app.Run(); diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor index 969cce11cb..ff1876dfc2 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor @@ -6,7 +6,27 @@ @inject IJSRuntime JS @implements IAsyncDisposable -
+
+ +
+
+ + @context.LineNumber + + @if (context.Timestamp is { } timestamp) + { + @GetDisplayTimestamp(timestamp) + } + @if (context.Type == LogEntryType.Error) + { + stderr + } + @((MarkupString)(context.Content ?? string.Empty)) + + +
+
+
diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs index 11fd2b29d9..841b2d624e 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Globalization; using Aspire.Dashboard.ConsoleLogs; +using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; @@ -14,30 +17,35 @@ namespace Aspire.Dashboard.Components; /// public sealed partial class LogViewer { - private readonly TaskCompletionSource _whenDomReady = new(); private readonly CancellationSeries _cancellationSeries = new(); - private IJSObjectReference? _jsModule; + private bool _convertTimestampsFromUtc; + private bool _applicationChanged; [Inject] public required BrowserTimeProvider TimeProvider { get; init; } protected override async Task OnAfterRenderAsync(bool firstRender) { + if (_applicationChanged) + { + await JS.InvokeVoidAsync("resetContinuousScrollPosition"); + _applicationChanged = false; + } if (firstRender) { - _jsModule ??= await JS.InvokeAsync("import", "/Components/Controls/LogViewer.razor.js"); - - _whenDomReady.TrySetResult(); + await JS.InvokeVoidAsync("initializeContinuousScroll"); } } - internal async Task SetLogSourceAsync(IAsyncEnumerable> batches, bool convertTimestampsFromUtc) + private readonly List _logEntries = new(); + private int? _baseLineNumber; + + internal async Task SetLogSourceAsync(IAsyncEnumerable> batches, bool convertTimestampsFromUtc) { - var cancellationToken = await _cancellationSeries.NextAsync(); - var logParser = new LogParser(TimeProvider, convertTimestampsFromUtc); + _convertTimestampsFromUtc = convertTimestampsFromUtc; - // Ensure we are able to write to the DOM. - await _whenDomReady.Task; + var cancellationToken = await _cancellationSeries.NextAsync(); + var logParser = new LogParser(); await foreach (var batch in batches.WithCancellation(cancellationToken)) { @@ -46,33 +54,105 @@ internal async Task SetLogSourceAsync(IAsyncEnumerable entries = new(batch.Count); - - foreach (var (content, isErrorOutput) in batch) + foreach (var (lineNumber, content, isErrorOutput) in batch) { - entries.Add(logParser.CreateLogEntry(content, isErrorOutput)); + // Keep track of the base line number to ensure that we can calculate the line number of each log entry. + // This becomes important when the total number of log entries exceeds the limit and is truncated. + if (_baseLineNumber is null) + { + _baseLineNumber = lineNumber; + } + + InsertSorted(_logEntries, logParser.CreateLogEntry(content, isErrorOutput)); } - await _jsModule!.InvokeVoidAsync("addLogEntries", cancellationToken, entries); + StateHasChanged(); } } - internal async Task ClearLogsAsync(CancellationToken cancellationToken = default) + private void InsertSorted(List logEntries, LogEntry logEntry) { - await _cancellationSeries.ClearAsync(); + if (logEntry.ParentId != null) + { + // If we have a parent id, then we know we're on a non-timestamped line that is part + // of a multi-line log entry. We need to find the prior line from that entry + for (var rowIndex = logEntries.Count - 1; rowIndex >= 0; rowIndex--) + { + var current = logEntries[rowIndex]; - if (_jsModule is not null) + if (current.Id == logEntry.ParentId && logEntry.LineIndex - 1 == current.LineIndex) + { + InsertLogEntry(logEntries, rowIndex + 1, logEntry); + return; + } + } + } + else if (logEntry.Timestamp != null) { - await _jsModule.InvokeVoidAsync("clearLogs", cancellationToken); + // Otherwise, if we have a timestamped line, we just need to find the prior line. + // Since the rows are always in order, as soon as we see a timestamp + // that is less than the one we're adding, we can insert it immediately after that + for (var rowIndex = logEntries.Count - 1; rowIndex >= 0; rowIndex--) + { + var current = logEntries[rowIndex]; + var currentTimestamp = current.Timestamp ?? current.ParentTimestamp; + + if (currentTimestamp != null && currentTimestamp < logEntry.Timestamp) + { + InsertLogEntry(logEntries, rowIndex + 1, logEntry); + return; + } + } + } + + // If we didn't find a place to insert then append it to the end. This happens with the first entry, but + // could also happen if the logs don't have recognized timestamps. + InsertLogEntry(logEntries, logEntries.Count, logEntry); + + void InsertLogEntry(List logEntries, int index, LogEntry logEntry) + { + // Set the line number of the log entry. + if (index == 0) + { + Debug.Assert(_baseLineNumber != null, "Should be set before this method is run."); + logEntry.LineNumber = _baseLineNumber.Value; + } + else + { + logEntry.LineNumber = logEntries[index - 1].LineNumber + 1; + } + + logEntries.Insert(index, logEntry); + + // If a log entry isn't inserted at the end then update the line numbers of all subsequent entries. + for (var i = index + 1; i < logEntries.Count; i++) + { + logEntries[i].LineNumber++; + } } } - public async ValueTask DisposeAsync() + private string GetDisplayTimestamp(DateTimeOffset timestamp) { - _whenDomReady.TrySetCanceled(); + if (_convertTimestampsFromUtc) + { + timestamp = TimeProvider.ToLocal(timestamp); + } + + return timestamp.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture); + } + internal async Task ClearLogsAsync() + { await _cancellationSeries.ClearAsync(); - await JSInteropHelpers.SafeDisposeAsync(_jsModule); + _applicationChanged = true; + _logEntries.Clear(); + StateHasChanged(); + } + + public async ValueTask DisposeAsync() + { + await _cancellationSeries.ClearAsync(); } } diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.css b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.css index 915c501a55..97f2726d3b 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.css @@ -34,7 +34,7 @@ background: var(--console-background-color); color: var(--console-font-color); font-family: 'Cascadia Mono', Consolas, monospace; - font-size: 12px; + font-size: 13px; margin: 16px 0 0 0; padding-bottom: 24px; line-height: 20px; @@ -42,13 +42,11 @@ display: flex; flex-direction: column; width: 100%; - counter-reset: line-number 0; } ::deep .line-row-container { width: 100%; overflow: hidden; - counter-increment: line-number 1; } ::deep .line-row { @@ -79,10 +77,8 @@ align-self: flex-start; flex-shrink: 0; color: var(--line-number-color); -} - -::deep .line-number::before { - content: counter(line-number); + user-select: none; + cursor: default; } ::deep .content { diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.js b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.js deleted file mode 100644 index f098bb2eb8..0000000000 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.js +++ /dev/null @@ -1,176 +0,0 @@ -const template = createRowTemplate(); -const stdErrorBadgeTemplate = createStdErrBadgeTemplate(); - -/** - * Clears all log entries from the log viewer and resets the - * row index back to 1 - */ -export function clearLogs() { - const container = document.getElementById("logContainer"); - container.textContent = ''; -} - -/** - * Adds a series of log entries to the log viewer and, if appropriate - * scrolls the log viewer to the bottom - * @param {LogEntry[]} logEntries - */ -export function addLogEntries(logEntries) { - - const container = document.getElementById("logContainer"); - - if (container) { - const scrollingContainer = container.parentElement; - const isScrolledToBottom = getIsScrolledToBottom(scrollingContainer); - - for (const logEntry of logEntries) { - - const rowContainer = getNewRowContainer(); - rowContainer.setAttribute("data-line-index", logEntry.lineIndex); - rowContainer.setAttribute("data-log-id", logEntry.id); - rowContainer.setAttribute("data-timestamp", logEntry.timestamp ?? logEntry.parentTimestamp ?? ""); - const lineRow = rowContainer.firstElementChild; - const lineArea = lineRow.firstElementChild; - const content = lineArea.lastElementChild; - - // logEntry.content should already be HTMLEncoded other than the s produced - // by the ANSI Control Sequence Parsing, so it should be safe to set innerHTML here - content.innerHTML = logEntry.content; - - if (logEntry.type === "Error") { - const stdErrorBadge = getStdErrorBadge(); - // If there's a timestamp, we want to put the badge after it to keep timestamps - // aligned. If there's not, then we just put the badge at the start of the content - const timestampSpan = content.querySelector(".timestamp"); - if (timestampSpan) { - timestampSpan.after(stdErrorBadge); - } else { - content.prepend(stdErrorBadge); - } - } - - insertSorted(container, rowContainer, logEntry.timestamp, logEntry.parentId, logEntry.lineIndex); - } - - // If we were scrolled all the way to the bottom before we added the new - // element, then keep us scrolled to the bottom. Otherwise let the user - // stay where they are - if (isScrolledToBottom) { - scrollingContainer.scrollTop = scrollingContainer.scrollHeight; - } - } -} - -/** - * - * @param {HTMLElement} container - * @param {HTMLElement} row - * @param {string} timestamp - * @param {string} parentLogId - * @param {number} lineIndex - */ -function insertSorted(container, row, timestamp, parentId, lineIndex) { - - let prior = null; - - if (parentId) { - // If we have a parent id, then we know we're on a non-timestamped line that is part - // of a multi-line log entry. We need to find the prior line from that entry - prior = container.querySelector(`div[data-log-id="${parentId}"][data-line-index="${lineIndex - 1}"]`); - } else if (timestamp) { - // Otherwise, if we have a timestamped line, we just need to find the prior line. - // Since the rows are always in order in the DOM, as soon as we see a timestamp - // that is less than the one we're adding, we can insert it immediately after that - for (let rowIndex = container.children.length - 1; rowIndex >= 0; rowIndex--) { - const targetRow = container.children[rowIndex]; - const targetRowTimestamp = targetRow.getAttribute("data-timestamp"); - - if (targetRowTimestamp && targetRowTimestamp < timestamp) { - prior = targetRow; - break; - } - } - } - - if (prior) { - // If we found the prior row using either method above, go ahead and insert the new row after it - prior.after(row); - } else { - // If we didn't, then just append it to the end. This happens with the first entry, but - // could also happen if the logs don't have recognized timestamps. - container.appendChild(row); - } -} - -/** - * Clones the row container template for use with a new log entry - * @returns {HTMLElement} - */ -function getNewRowContainer() { - return template.cloneNode(true); -} - -/** - * Clones the stderr badge template for use with a new log entry - * @returns - */ -function getStdErrorBadge() { - return stdErrorBadgeTemplate.cloneNode(true); -} - -/** - * Creates the initial row container template that will be cloned - * for each log entry - * @returns {HTMLElement} - */ -function createRowTemplate() { - - const templateString = ` -
-
- - - - -
-
- `; - const templateElement = document.createElement("template"); - templateElement.innerHTML = templateString.trim(); - const rowTemplate = templateElement.content.firstChild; - return rowTemplate; -} - -/** - * Creates the initial stderr badge template that will be cloned - * for each log entry - * @returns {HTMLElement} - */ -function createStdErrBadgeTemplate() { - const badge = document.createElement("fluent-badge"); - badge.setAttribute("appearance", "accent"); - badge.textContent = "stderr"; - return badge; -} - -/** - * Checks to see if the specified scrolling container is scrolled all the way - * to the bottom - * @param {HTMLElement} scrollingContainer - * @returns {boolean} - */ -function getIsScrolledToBottom(scrollingContainer) { - return scrollingContainer.scrollHeight - scrollingContainer.clientHeight <= scrollingContainer.scrollTop + 1; -} - -/** - * @typedef LogEntry - * @prop {string} timestamp - * @prop {string} content - * @prop {"Default" | "Error" | "Warning"} type - * @prop {string} id - * @prop {string} parentId - * @prop {number} lineIndex - * @prop {string} parentTimestamp - * @prop {boolean} isFirstLine - */ diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index 00b6808119..a6867374af 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -166,7 +166,10 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await JS.InvokeVoidAsync("resetContinuousScrollPosition"); _applicationChanged = false; } - await JS.InvokeVoidAsync("initializeContinuousScroll"); + if (firstRender) + { + await JS.InvokeVoidAsync("initializeContinuousScroll"); + } } public void Dispose() diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs b/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs index 83b2ebc2b6..c3bc01f2a6 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs @@ -1,26 +1,26 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json.Serialization; +using System.Diagnostics; namespace Aspire.Dashboard.Model; +[DebuggerDisplay("Timestamp = {(Timestamp ?? ParentTimestamp),nq}, Content = {Content}")] internal sealed partial class LogEntry { public string? Content { get; set; } - public string? Timestamp { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] + public DateTimeOffset? Timestamp { get; set; } public LogEntryType Type { get; init; } = LogEntryType.Default; public int LineIndex { get; set; } public Guid? ParentId { get; set; } public Guid Id { get; } = Guid.NewGuid(); - public string? ParentTimestamp { get; set; } + public DateTimeOffset? ParentTimestamp { get; set; } public bool IsFirstLine { get; init; } + public int LineNumber { get; set; } } internal enum LogEntryType { Default, - Error, - Warning + Error } diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs b/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs index bec1ae4a35..be07607b95 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs @@ -6,9 +6,9 @@ namespace Aspire.Dashboard.ConsoleLogs; -internal sealed partial class LogParser(BrowserTimeProvider timeProvider, bool convertTimestampsFromUtc) +internal sealed class LogParser { - private string? _parentTimestamp; + private DateTimeOffset? _parentTimestamp; private Guid? _parentId; private int _lineIndex; private AnsiParser.ParserState? _residualState; @@ -17,35 +17,40 @@ public LogEntry CreateLogEntry(string rawText, bool isErrorOutput) { // Several steps to do here: // - // 1. HTML Encode the raw text for security purposes - // 2. Parse the content to look for the timestamp and color it if possible - // 3. Parse the content to look for info/warn/dbug header + // 1. Parse the content to look for the timestamp + // 2. Parse the content to look for info/warn/dbug header + // 3. HTML Encode the raw text for security purposes // 4. Parse the content to look for ANSI Control Sequences and color them if possible // 5. Parse the content to look for URLs and make them links if possible // 6. Create the LogEntry to get the ID // 7. Set the relative properties of the log entry (parent/line index/etc) // 8. Return the final result - // 1. HTML Encode the raw text for security purposes - var content = WebUtility.HtmlEncode(rawText); + var content = rawText; - // 2. Parse the content to look for the timestamp and color it if possible + // 1. Parse the content to look for the timestamp var isFirstLine = false; - string? timestamp = null; + DateTimeOffset? timestamp = null; - if (TimestampParser.TryColorizeTimestamp(timeProvider, content, convertTimestampsFromUtc, out var timestampParseResult)) + if (TimestampParser.TryParseConsoleTimestamp(content, out var timestampParseResult)) { isFirstLine = true; - content = timestampParseResult.ModifiedText; - timestamp = timestampParseResult.Timestamp; + content = timestampParseResult.Value.ModifiedText; + timestamp = timestampParseResult.Value.Timestamp; } - // 3. Parse the content to look for info/warn/dbug header + // 2. Parse the content to look for info/warn/dbug header // TODO extract log level and use here - else if (LogLevelParser.StartsWithLogLevelHeader(content)) + else { - isFirstLine = true; + if (LogLevelParser.StartsWithLogLevelHeader(content)) + { + isFirstLine = true; + } } + // 3. HTML Encode the raw text for security purposes + content = WebUtility.HtmlEncode(content); + // 4. Parse the content to look for ANSI Control Sequences and color them if possible var conversionResult = AnsiParser.ConvertToHtml(content, _residualState); content = conversionResult.ConvertedText; @@ -58,7 +63,7 @@ public LogEntry CreateLogEntry(string rawText, bool isErrorOutput) } // 6. Create the LogEntry to get the ID - var logEntry = new LogEntry() + var logEntry = new LogEntry { Timestamp = timestamp, Content = content, diff --git a/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs b/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs index e56dedbe5b..9b5d67ae31 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.RegularExpressions; using Aspire.Dashboard.Extensions; @@ -12,7 +13,7 @@ public static partial class TimestampParser { private static readonly Regex s_rfc3339RegEx = GenerateRfc3339RegEx(); - public static bool TryColorizeTimestamp(BrowserTimeProvider timeProvider, string text, bool convertTimestampsFromUtc, out TimestampParserResult result) + public static bool TryParseConsoleTimestamp(string text, [NotNullWhen(true)] out TimestampParserResult? result) { var match = s_rfc3339RegEx.Match(text); @@ -22,10 +23,7 @@ public static bool TryColorizeTimestamp(BrowserTimeProvider timeProvider, string var timestamp = span[match.Index..(match.Index + match.Length)]; var theRest = match.Index + match.Length >= span.Length ? "" : span[(match.Index + match.Length)..]; - var timestampForDisplay = convertTimestampsFromUtc ? ConvertTimestampFromUtc(timeProvider, timestamp) : timestamp.ToString(); - - var modifiedText = $"{timestampForDisplay}{theRest}"; - result = new(modifiedText, timestamp.ToString()); + result = new(theRest.ToString(), DateTimeOffset.Parse(timestamp.ToString(), CultureInfo.InvariantCulture)); return true; } @@ -33,7 +31,7 @@ public static bool TryColorizeTimestamp(BrowserTimeProvider timeProvider, string return false; } - private static string ConvertTimestampFromUtc(BrowserTimeProvider timeProvider, ReadOnlySpan timestamp) + public static string ConvertTimestampFromUtc(BrowserTimeProvider timeProvider, ReadOnlySpan timestamp) { if (DateTimeOffset.TryParse(timestamp, out var dateTimeUtc)) { @@ -75,5 +73,5 @@ private static string ConvertTimestampFromUtc(BrowserTimeProvider timeProvider, [GeneratedRegex("^(?:\\d{4})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):(?:[0-5][0-9]):(?:[0-5][0-9])(?:\\.\\d{1,9})?(?:Z|(?:[Z+-](?:[01][0-9]|2[0-3]):(?:[0-5][0-9])))?")] private static partial Regex GenerateRfc3339RegEx(); - public readonly record struct TimestampParserResult(string ModifiedText, string Timestamp); + public readonly record struct TimestampParserResult(string ModifiedText, DateTimeOffset Timestamp); } diff --git a/src/Aspire.Dashboard/Model/DashboardClient.cs b/src/Aspire.Dashboard/Model/DashboardClient.cs index 16e3e6887a..734ba9da4c 100644 --- a/src/Aspire.Dashboard/Model/DashboardClient.cs +++ b/src/Aspire.Dashboard/Model/DashboardClient.cs @@ -434,7 +434,7 @@ async IAsyncEnumerable> StreamUpdatesAsyn } } - async IAsyncEnumerable>? IDashboardClient.SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) + async IAsyncEnumerable>? IDashboardClient.SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { EnsureInitialized(); @@ -446,7 +446,7 @@ async IAsyncEnumerable> StreamUpdatesAsyn // Write incoming logs to a channel, and then read from that channel to yield the logs. // We do this to batch logs together and enforce a minimum read interval. - var channel = Channel.CreateUnbounded>( + var channel = Channel.CreateUnbounded>( new UnboundedChannelOptions { AllowSynchronousContinuations = false, SingleReader = true, SingleWriter = true }); var readTask = Task.Run(async () => @@ -455,11 +455,11 @@ async IAsyncEnumerable> StreamUpdatesAsyn { await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: combinedTokens.Token)) { - var logLines = new (string Content, bool IsErrorMessage)[response.LogLines.Count]; + var logLines = new ResourceLogLine[response.LogLines.Count]; for (var i = 0; i < logLines.Length; i++) { - logLines[i] = (response.LogLines[i].Text, response.LogLines[i].IsStdErr); + logLines[i] = new ResourceLogLine(response.LogLines[i].LineNumber, response.LogLines[i].Text, response.LogLines[i].IsStdErr); } // Channel is unbound so TryWrite always succeeds. diff --git a/src/Aspire.Dashboard/Model/IDashboardClient.cs b/src/Aspire.Dashboard/Model/IDashboardClient.cs index 8118692dfb..9eb134ccd9 100644 --- a/src/Aspire.Dashboard/Model/IDashboardClient.cs +++ b/src/Aspire.Dashboard/Model/IDashboardClient.cs @@ -49,7 +49,7 @@ public interface IDashboardClient : IAsyncDisposable /// /// It is important that callers trigger /// so that resources owned by the sequence and its consumers can be freed. - IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken); + IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken); Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken); } diff --git a/src/Aspire.Dashboard/Model/ResourceLogLine.cs b/src/Aspire.Dashboard/Model/ResourceLogLine.cs new file mode 100644 index 0000000000..8fca075dc8 --- /dev/null +++ b/src/Aspire.Dashboard/Model/ResourceLogLine.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model; + +public readonly record struct ResourceLogLine(int LineNumber, string Content, bool IsErrorMessage) +{ +} diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 54454a9a1b..da84c50dff 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -15,10 +15,21 @@ if (firstUndefinedElement) { } let isScrolledToContent = false; +let lastScrollHeight = null; + +window.getIsScrolledToContent = function () { + return isScrolledToContent; +} + +window.setIsScrolledToContent = function (value) { + if (isScrolledToContent != value) { + isScrolledToContent = value; + } +} window.resetContinuousScrollPosition = function () { // Reset to scrolling to the end of the content after switching. - isScrolledToContent = false; + setIsScrolledToContent(false); } window.initializeContinuousScroll = function () { @@ -33,15 +44,17 @@ window.initializeContinuousScroll = function () { // The scroll event is used to detect when the user scrolls to view content. container.addEventListener('scroll', () => { - isScrolledToContent = !isScrolledToBottom(container); - }, { passive: true }); + var v = !isScrolledToBottom(container); + setIsScrolledToContent(v); + }, { passive: true }); // The ResizeObserver reports changes in the grid size. // This ensures that the logs are scrolled to the bottom when there are new logs // unless the user has scrolled to view content. const observer = new ResizeObserver(function () { - if (!isScrolledToContent) { - container.scrollTop = container.scrollHeight; + lastScrollHeight = container.scrollHeight; + if (!getIsScrolledToContent()) { + container.scrollTop = lastScrollHeight; } }); for (const child of container.children) { @@ -50,10 +63,21 @@ window.initializeContinuousScroll = function () { }; function isScrolledToBottom(container) { - // Small margin of error. e.g. container is scrolled to within 5px of the bottom. + lastScrollHeight = lastScrollHeight || container.scrollHeight + + // There can be a race between resizing and scrolling events. + // Use the last scroll height from the resize event to figure out if we've scrolled to the bottom. + if (!getIsScrolledToContent()) { + if (lastScrollHeight != container.scrollHeight) { + console.log(`lastScrollHeight ${lastScrollHeight} doesn't equal container scrollHeight ${container.scrollHeight}.`); + } + } + const marginOfError = 5; + const containerScrollBottom = lastScrollHeight - container.clientHeight; + const difference = containerScrollBottom - container.scrollTop; - return container.scrollHeight - container.clientHeight <= container.scrollTop + marginOfError; + return difference < marginOfError; } window.buttonCopyTextToClipboard = function(element) { diff --git a/src/Aspire.Hosting/ApplicationModel/LogLine.cs b/src/Aspire.Hosting/ApplicationModel/LogLine.cs new file mode 100644 index 0000000000..af8f756375 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/LogLine.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a console log line. +/// +/// The line number. +/// The content. +/// A value indicating whether the log line is error output. +public readonly record struct LogLine(int LineNumber, string Content, bool IsErrorMessage) +{ +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs index ecef4d9058..b9b793df6d 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs @@ -44,7 +44,7 @@ public ILogger GetLogger(string resourceName) /// /// The resource name /// - public IAsyncEnumerable> WatchAsync(string resourceName) + public IAsyncEnumerable> WatchAsync(string resourceName) { ArgumentNullException.ThrowIfNull(resourceName); @@ -56,7 +56,7 @@ public ILogger GetLogger(string resourceName) /// /// The resource to watch for logs. /// - public IAsyncEnumerable> WatchAsync(IResource resource) + public IAsyncEnumerable> WatchAsync(IResource resource) { ArgumentNullException.ThrowIfNull(resource); @@ -103,7 +103,7 @@ private sealed class ResourceLoggerState private readonly CancellationTokenSource _logStreamCts = new(); // History of logs, capped at 10000 entries. - private readonly CircularBuffer<(string Content, bool IsErrorMessage)> _backlog = new(10000); + private readonly CircularBuffer _backlog = new(10000); /// /// Creates a new . @@ -117,7 +117,7 @@ public ResourceLoggerState() /// Watch for changes to the log stream for a resource. /// /// The log stream for the resource. - public IAsyncEnumerable> WatchAsync() + public IAsyncEnumerable> WatchAsync() { lock (_backlog) { @@ -127,7 +127,7 @@ public ResourceLoggerState() } // This provides the fan out to multiple subscribers. - private Action<(string, bool)>? OnNewLog { get; set; } + private Action? OnNewLog { get; set; } /// /// The logger for the resource to write to. This will write updates to the live log stream for this resource. @@ -145,6 +145,8 @@ public void Complete() private sealed class ResourceLogger(ResourceLoggerState annotation) : ILogger { + private int _lineNumber; + IDisposable? ILogger.BeginScope(TState state) => null; bool ILogger.IsEnabled(LogLevel logLevel) => true; @@ -160,31 +162,33 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except var log = formatter(state, exception) + (exception is null ? "" : $"\n{exception}"); var isErrorMessage = logLevel >= LogLevel.Error; - var payload = (log, isErrorMessage); - + LogLine logLine; lock (annotation._backlog) { - annotation._backlog.Add(payload); + _lineNumber++; + logLine = new LogLine(_lineNumber, log, isErrorMessage); + + annotation._backlog.Add(logLine); } - annotation.OnNewLog?.Invoke(payload); + annotation.OnNewLog?.Invoke(logLine); } } - private sealed class LogAsyncEnumerable(ResourceLoggerState annotation, List<(string, bool)> backlogSnapshot) : IAsyncEnumerable> + private sealed class LogAsyncEnumerable(ResourceLoggerState annotation, List backlogSnapshot) : IAsyncEnumerable> { - public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) + public async IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) { if (backlogSnapshot.Count > 0) { yield return backlogSnapshot; } - var channel = Channel.CreateUnbounded<(string, bool)>(); + var channel = Channel.CreateUnbounded(); using var _ = annotation._logStreamCts.Token.Register(() => channel.Writer.TryComplete()); - void Log((string Content, bool IsErrorMessage) log) + void Log(LogLine log) { channel.Writer.TryWrite(log); } diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 16cec459aa..6be533150c 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -133,9 +133,9 @@ async Task WatchResourceConsoleLogsInternal() { WatchResourceConsoleLogsUpdate update = new(); - foreach (var (content, isErrorMessage) in group) + foreach (var (lineNumber, content, isErrorMessage) in group) { - update.LogLines.Add(new ConsoleLogLine() { Text = content, IsStdErr = isErrorMessage }); + update.LogLines.Add(new ConsoleLogLine() { LineNumber = lineNumber, Text = content, IsStdErr = isErrorMessage }); } await responseStream.WriteAsync(update, cts.Token).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index bea99176f7..a5aee59b98 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -81,13 +81,13 @@ internal ResourceSnapshotSubscription SubscribeResources() return _resourcePublisher.Subscribe(); } - internal IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName) + internal IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName) { var sequence = _resourceLoggerService.WatchAsync(resourceName); return sequence is null ? null : Enumerate(); - async IAsyncEnumerable> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken = default) + async IAsyncEnumerable> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken = default) { using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); diff --git a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto index 67231bb86b..376031cb41 100644 --- a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto @@ -208,6 +208,7 @@ message ConsoleLogLine { string text = 1; // Indicates whether this line came from STDERR or not. optional bool is_std_err = 2; + int32 line_number = 3; } // Initiates a subscription for the logs of a resource. diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs index eb0e2a1740..54d11f1ab5 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs @@ -51,7 +51,7 @@ private sealed class MockDashboardClient : IDashboardClient public string ApplicationName => "An HTML title!"; public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken) => throw new NotImplementedException(); - public IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); } } diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs index 43279dac5a..0712e0b907 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs @@ -1,9 +1,8 @@ // 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 Aspire.Dashboard.ConsoleLogs; -using Aspire.Dashboard.Model; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Aspire.Dashboard.Tests.ConsoleLogsTests; @@ -16,23 +15,32 @@ public class TimestampParserTests [InlineData("This is some text without any timestamp")] public void TryColorizeTimestamp_DoesNotStartWithTimestamp_ReturnsFalse(string input) { - var result = TimestampParser.TryColorizeTimestamp(CreateTimeProvider(), input, convertTimestampsFromUtc: false, out var _); + var result = TimestampParser.TryParseConsoleTimestamp(input, out var _); Assert.False(result); } [Theory] - [InlineData("2023-10-10T15:05:30.123456789Z", true, "2023-10-10T15:05:30.123456789Z", "2023-10-10T15:05:30.123456789Z")] - [InlineData("2023-10-10T15:05:30.123456789Z ", true, "2023-10-10T15:05:30.123456789Z ", "2023-10-10T15:05:30.123456789Z")] - [InlineData("2023-10-10T15:05:30.123456789Z with some text after it", true, "2023-10-10T15:05:30.123456789Z with some text after it", "2023-10-10T15:05:30.123456789Z")] + [InlineData("2023-10-10T15:05:30.123456789Z", true, "", "2023-10-10T15:05:30.123456789Z")] + [InlineData("2023-10-10T15:05:30.123456789Z ", true, " ", "2023-10-10T15:05:30.123456789Z")] + [InlineData("2023-10-10T15:05:30.123456789Z with some text after it", true, " with some text after it", "2023-10-10T15:05:30.123456789Z")] [InlineData("With some text before it 2023-10-10T15:05:30.123456789Z", false, null, null)] public void TryColorizeTimestamp_ReturnsCorrectResult(string input, bool expectedResult, string? expectedOutput, string? expectedTimestamp) { - var result = TimestampParser.TryColorizeTimestamp(CreateTimeProvider(), input, convertTimestampsFromUtc: false, out var parseResult); + var result = TimestampParser.TryParseConsoleTimestamp(input, out var parseResult); Assert.Equal(expectedResult, result); - Assert.Equal(expectedOutput, parseResult.ModifiedText); - Assert.Equal(expectedTimestamp, parseResult.Timestamp); + + if (result) + { + Assert.NotNull(parseResult); + Assert.Equal(expectedOutput, parseResult.Value.ModifiedText); + Assert.Equal(expectedTimestamp != null ? (DateTimeOffset?)DateTimeOffset.Parse(expectedTimestamp, CultureInfo.InvariantCulture) : null, parseResult.Value.Timestamp); + } + else + { + Assert.Null(parseResult); + } } [Theory] @@ -51,13 +59,8 @@ public void TryColorizeTimestamp_ReturnsCorrectResult(string input, bool expecte [InlineData("2023-10-10T15:05:30.123456789")] public void TryColorizeTimestamp_SupportedTimestampFormats(string input) { - var result = TimestampParser.TryColorizeTimestamp(CreateTimeProvider(), input, convertTimestampsFromUtc: false, out var _); + var result = TimestampParser.TryParseConsoleTimestamp(input, out var _); Assert.True(result); } - - private static BrowserTimeProvider CreateTimeProvider() - { - return new BrowserTimeProvider(NullLoggerFactory.Instance); - } } diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 4f01fb7f7f..9e86b30c05 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -181,7 +181,7 @@ private sealed class MockDashboardClient(Task sub public string ApplicationName => "ApplicationName"; public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken) => throw new NotImplementedException(); - public IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable>? SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => subscribeResult; } }