Skip to content

Commit b5c353b

Browse files
authored
Refactor console logs to render on server and use virtualization (dotnet#3075)
1 parent 47d5440 commit b5c353b

File tree

21 files changed

+308
-282
lines changed

21 files changed

+308
-282
lines changed

playground/Stress/Stress.ApiService/Program.cs

+46
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics;
5+
using System.Threading.Channels;
46
using Stress.ApiService;
57

68
var builder = WebApplication.CreateBuilder(args);
@@ -25,4 +27,48 @@
2527
return "Big trace created";
2628
});
2729

30+
app.MapGet("/many-logs", (ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
31+
{
32+
var channel = Channel.CreateUnbounded<string>();
33+
var logger = loggerFactory.CreateLogger("ManyLogs");
34+
35+
cancellationToken.Register(() =>
36+
{
37+
logger.LogInformation("Writing logs canceled.");
38+
});
39+
40+
// Write logs for 1 minute.
41+
_ = Task.Run(async () =>
42+
{
43+
var stopwatch = Stopwatch.StartNew();
44+
var logCount = 0;
45+
while (stopwatch.Elapsed < TimeSpan.FromMinutes(1))
46+
{
47+
cancellationToken.ThrowIfCancellationRequested();
48+
49+
logCount++;
50+
logger.LogInformation("This is log message {LogCount}.", logCount);
51+
52+
if (logCount % 100 == 0)
53+
{
54+
channel.Writer.TryWrite($"Logged {logCount} messages.");
55+
}
56+
57+
await Task.Delay(5, cancellationToken);
58+
}
59+
60+
channel.Writer.Complete();
61+
}, cancellationToken);
62+
63+
return WriteOutput();
64+
65+
async IAsyncEnumerable<string> WriteOutput()
66+
{
67+
await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken))
68+
{
69+
yield return message;
70+
}
71+
}
72+
});
73+
2874
app.Run();

src/Aspire.Dashboard/Components/Controls/LogViewer.razor

+21-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,27 @@
66
@inject IJSRuntime JS
77
@implements IAsyncDisposable
88

9-
<div class="log-overflow">
9+
<div class="log-overflow continuous-scroll-overflow">
1010
<div class="log-container" id="logContainer">
11+
<Virtualize Items="_logEntries" ItemSize="20" OverscanCount="100">
12+
<div class="line-row-container">
13+
<div class="line-row">
14+
<span class="line-area" role="log">
15+
<span class="line-number">@context.LineNumber</span>
16+
<span class="content">
17+
@if (context.Timestamp is { } timestamp)
18+
{
19+
<span class="timestamp">@GetDisplayTimestamp(timestamp)</span>
20+
}
21+
@if (context.Type == LogEntryType.Error)
22+
{
23+
<fluent-badge appearance="accent">stderr</fluent-badge>
24+
}
25+
@((MarkupString)(context.Content ?? string.Empty))
26+
</span>
27+
</span>
28+
</div>
29+
</div>
30+
</Virtualize>
1131
</div>
1232
</div>
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics;
5+
using System.Globalization;
46
using Aspire.Dashboard.ConsoleLogs;
7+
using Aspire.Dashboard.Extensions;
58
using Aspire.Dashboard.Model;
69
using Aspire.Dashboard.Utils;
710
using Microsoft.AspNetCore.Components;
@@ -14,30 +17,35 @@ namespace Aspire.Dashboard.Components;
1417
/// </summary>
1518
public sealed partial class LogViewer
1619
{
17-
private readonly TaskCompletionSource _whenDomReady = new();
1820
private readonly CancellationSeries _cancellationSeries = new();
19-
private IJSObjectReference? _jsModule;
21+
private bool _convertTimestampsFromUtc;
22+
private bool _applicationChanged;
2023

2124
[Inject]
2225
public required BrowserTimeProvider TimeProvider { get; init; }
2326

2427
protected override async Task OnAfterRenderAsync(bool firstRender)
2528
{
29+
if (_applicationChanged)
30+
{
31+
await JS.InvokeVoidAsync("resetContinuousScrollPosition");
32+
_applicationChanged = false;
33+
}
2634
if (firstRender)
2735
{
28-
_jsModule ??= await JS.InvokeAsync<IJSObjectReference>("import", "/Components/Controls/LogViewer.razor.js");
29-
30-
_whenDomReady.TrySetResult();
36+
await JS.InvokeVoidAsync("initializeContinuousScroll");
3137
}
3238
}
3339

34-
internal async Task SetLogSourceAsync(IAsyncEnumerable<IReadOnlyList<(string Content, bool IsErrorMessage)>> batches, bool convertTimestampsFromUtc)
40+
private readonly List<LogEntry> _logEntries = new();
41+
private int? _baseLineNumber;
42+
43+
internal async Task SetLogSourceAsync(IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> batches, bool convertTimestampsFromUtc)
3544
{
36-
var cancellationToken = await _cancellationSeries.NextAsync();
37-
var logParser = new LogParser(TimeProvider, convertTimestampsFromUtc);
45+
_convertTimestampsFromUtc = convertTimestampsFromUtc;
3846

39-
// Ensure we are able to write to the DOM.
40-
await _whenDomReady.Task;
47+
var cancellationToken = await _cancellationSeries.NextAsync();
48+
var logParser = new LogParser();
4149

4250
await foreach (var batch in batches.WithCancellation(cancellationToken))
4351
{
@@ -46,33 +54,105 @@ internal async Task SetLogSourceAsync(IAsyncEnumerable<IReadOnlyList<(string Con
4654
continue;
4755
}
4856

49-
List<LogEntry> entries = new(batch.Count);
50-
51-
foreach (var (content, isErrorOutput) in batch)
57+
foreach (var (lineNumber, content, isErrorOutput) in batch)
5258
{
53-
entries.Add(logParser.CreateLogEntry(content, isErrorOutput));
59+
// Keep track of the base line number to ensure that we can calculate the line number of each log entry.
60+
// This becomes important when the total number of log entries exceeds the limit and is truncated.
61+
if (_baseLineNumber is null)
62+
{
63+
_baseLineNumber = lineNumber;
64+
}
65+
66+
InsertSorted(_logEntries, logParser.CreateLogEntry(content, isErrorOutput));
5467
}
5568

56-
await _jsModule!.InvokeVoidAsync("addLogEntries", cancellationToken, entries);
69+
StateHasChanged();
5770
}
5871
}
5972

60-
internal async Task ClearLogsAsync(CancellationToken cancellationToken = default)
73+
private void InsertSorted(List<LogEntry> logEntries, LogEntry logEntry)
6174
{
62-
await _cancellationSeries.ClearAsync();
75+
if (logEntry.ParentId != null)
76+
{
77+
// If we have a parent id, then we know we're on a non-timestamped line that is part
78+
// of a multi-line log entry. We need to find the prior line from that entry
79+
for (var rowIndex = logEntries.Count - 1; rowIndex >= 0; rowIndex--)
80+
{
81+
var current = logEntries[rowIndex];
6382

64-
if (_jsModule is not null)
83+
if (current.Id == logEntry.ParentId && logEntry.LineIndex - 1 == current.LineIndex)
84+
{
85+
InsertLogEntry(logEntries, rowIndex + 1, logEntry);
86+
return;
87+
}
88+
}
89+
}
90+
else if (logEntry.Timestamp != null)
6591
{
66-
await _jsModule.InvokeVoidAsync("clearLogs", cancellationToken);
92+
// Otherwise, if we have a timestamped line, we just need to find the prior line.
93+
// Since the rows are always in order, as soon as we see a timestamp
94+
// that is less than the one we're adding, we can insert it immediately after that
95+
for (var rowIndex = logEntries.Count - 1; rowIndex >= 0; rowIndex--)
96+
{
97+
var current = logEntries[rowIndex];
98+
var currentTimestamp = current.Timestamp ?? current.ParentTimestamp;
99+
100+
if (currentTimestamp != null && currentTimestamp < logEntry.Timestamp)
101+
{
102+
InsertLogEntry(logEntries, rowIndex + 1, logEntry);
103+
return;
104+
}
105+
}
106+
}
107+
108+
// If we didn't find a place to insert then append it to the end. This happens with the first entry, but
109+
// could also happen if the logs don't have recognized timestamps.
110+
InsertLogEntry(logEntries, logEntries.Count, logEntry);
111+
112+
void InsertLogEntry(List<LogEntry> logEntries, int index, LogEntry logEntry)
113+
{
114+
// Set the line number of the log entry.
115+
if (index == 0)
116+
{
117+
Debug.Assert(_baseLineNumber != null, "Should be set before this method is run.");
118+
logEntry.LineNumber = _baseLineNumber.Value;
119+
}
120+
else
121+
{
122+
logEntry.LineNumber = logEntries[index - 1].LineNumber + 1;
123+
}
124+
125+
logEntries.Insert(index, logEntry);
126+
127+
// If a log entry isn't inserted at the end then update the line numbers of all subsequent entries.
128+
for (var i = index + 1; i < logEntries.Count; i++)
129+
{
130+
logEntries[i].LineNumber++;
131+
}
67132
}
68133
}
69134

70-
public async ValueTask DisposeAsync()
135+
private string GetDisplayTimestamp(DateTimeOffset timestamp)
71136
{
72-
_whenDomReady.TrySetCanceled();
137+
if (_convertTimestampsFromUtc)
138+
{
139+
timestamp = TimeProvider.ToLocal(timestamp);
140+
}
141+
142+
return timestamp.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture);
143+
}
73144

145+
internal async Task ClearLogsAsync()
146+
{
74147
await _cancellationSeries.ClearAsync();
75148

76-
await JSInteropHelpers.SafeDisposeAsync(_jsModule);
149+
_applicationChanged = true;
150+
_logEntries.Clear();
151+
StateHasChanged();
152+
}
153+
154+
public async ValueTask DisposeAsync()
155+
{
156+
await _cancellationSeries.ClearAsync();
77157
}
78158
}

src/Aspire.Dashboard/Components/Controls/LogViewer.razor.css

+3-7
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,19 @@
3434
background: var(--console-background-color);
3535
color: var(--console-font-color);
3636
font-family: 'Cascadia Mono', Consolas, monospace;
37-
font-size: 12px;
37+
font-size: 13px;
3838
margin: 16px 0 0 0;
3939
padding-bottom: 24px;
4040
line-height: 20px;
4141
overflow: visible;
4242
display: flex;
4343
flex-direction: column;
4444
width: 100%;
45-
counter-reset: line-number 0;
4645
}
4746

4847
::deep .line-row-container {
4948
width: 100%;
5049
overflow: hidden;
51-
counter-increment: line-number 1;
5250
}
5351

5452
::deep .line-row {
@@ -79,10 +77,8 @@
7977
align-self: flex-start;
8078
flex-shrink: 0;
8179
color: var(--line-number-color);
82-
}
83-
84-
::deep .line-number::before {
85-
content: counter(line-number);
80+
user-select: none;
81+
cursor: default;
8682
}
8783

8884
::deep .content {

0 commit comments

Comments
 (0)