Blazor live logs view with syntax-highlighting [closed]

1 day ago 1
ARTICLE AD BOX

I am trying to create a blazor (server-side) component that shows live logs. In my case it is for an external process but it should be able to display any type of real-time logging.

What I am looking for:

Real time logging, no polling

Syntax highlighted

Limited to a certain amount of log entries

Scrolling up freezes the view so that user can read what is happening (though I guess the text itself will start moving once the log buffer is full, not sure how to get around that)

Logs get recorded by server so opening logs already has full history in it

My current approach:

Using Prism.js as highlighting library

Custom LimitedSizeObservableCollection that helps with points 3 and 5

using System.Collections.ObjectModel; namespace Commons; /// <summary> /// ObservableCollection that automatically removes oldest elements when it reaches specified size /// </summary> /// <typeparam name="T">Type of elements</typeparam> public class LimitedSizeObservableCollection<T> : ObservableCollection<T> { /// <summary> /// ObservableCollection that automatically removes oldest elements when it reaches specified size /// </summary> /// <typeparam name="T">Type of elements</typeparam> /// <param name="capacity">Maximum capacity of collection</param> /// <param name="clearCount">How many elements to remove once capacity is reached. Has to be lower than Capacity. Default: 10</param> public LimitedSizeObservableCollection(uint capacity, uint clearCount = 10) { Capacity = capacity; ClearCount = clearCount; if (Capacity < clearCount) { throw new ArgumentException($"Capacity [{Capacity}] has to be higher to clearCount [{clearCount}]", nameof(clearCount)); } } public uint Capacity { get; set; } public uint ClearCount { get; set; } protected override void InsertItem(int index, T item) { base.InsertItem(index, item); if (Count > Capacity) { // Remove last ClearCount items for (var i = 0; i < ClearCount && Count > 0; i++) { RemoveAt(Count - 1); } } } } ViewModel that handles locking to prevent an issue with exception on update if elements are added during rendering (this however seems to severely slow down the updates, causing waiting threads to pile up) using System.Diagnostics; using System.Text; using BusinessLogic.Services.Interfaces; using Commons; using CommunityToolkit.Mvvm.ComponentModel; using Serilog.Events; namespace Charon.Shared.UIComponents.Models; public partial class LogWindowViewModel : ObservableObject { public LimitedSizeObservableCollection<string> Logs { get; } private readonly Lock _logsGate = new(); private readonly SynchronizationContext _uiContext; public static IEnumerable<LogEventLevel> LogLevels => Enum.GetValues<LogEventLevel>(); [ObservableProperty] private LogEventLevel _logEventLevel = LogEventLevel.Information; private Process? ExtProcess => _ExtService.ExtProcess; private readonly IExtService _ExtService; public LogWindowViewModel(IExtService ExtService, uint maxLogs = 1500) { _uiContext = SynchronizationContext.Current ?? throw new InvalidOperationException("LogWindowViewModel must be constructed on the UI thread."); Logs = new LimitedSizeObservableCollection<string>(maxLogs); _ExtService = ExtService; _ExtService.ExtProcessChanged += RegisterExtLogListeners; // Workaround because direct binding to Logs with a converter does not work. Element does not react to PropertyChanged notification. See https://github.com/AvaloniaUI/Avalonia/issues/11610 } private void AddLog(string message) { _uiContext.Post(_ => { lock (_logsGate) { Logs.Add(message); } }, null); } public List<string> GetLogsSnapshot() { lock (_logsGate) { return Logs.ToList(); } } public string GetLogsTextSnapshot() { lock (_logsGate) { if (Logs.Count == 0) return string.Empty; var sb = new StringBuilder(capacity: Logs.Count * 64); foreach (var line in Logs) sb.AppendLine(line); return sb.ToString(); } } public void ClearLogs() { lock (_logsGate) { Logs.Clear(); } } private void RegisterExtLogListeners(object? sender, Process? ExtProcess) { if (ExtProcess == null) return; AddLog("Ext connected"); _ExtService.ChangeLogLevel(LogEventLevel); if (_ExtService.External) { AddLog("Process acquired externally. Logging not available"); return; } ExtProcess!.OutputDataReceived += (_, args) => { if (args.Data != null) AddLog(args.Data); }; ExtProcess!.ErrorDataReceived += (_, args) => { if (args.Data != null) AddLog(args.Data); }; } } LogsView, triggers an accompanying JS. I tried different approaches and this one worked best so far as much as I wanted to avoid JS @using Charon.Shared.UIComponents.Models @using System.Collections.Specialized @inject LogWindowViewModel LogWindowViewModel @inject IJSRuntime JSRuntime; @inject ILogger<LogsView> Logger; @implements IDisposable <MudPaper Style="height: 100%"> @if (_cleaning) { <MudPaper Class="d-flex flex-column-reverse" Style="height: 100%"> <MudSkeleton Class="ma-3 flex-1" SkeletonType="SkeletonType.Rectangle" Animation="Animation.Wave" /> </MudPaper> } else { <pre class="sticky-scroll-container" style="height: 100%;"> <code id="logsCode" class="language-log code-wrap"></code> <div id="anchor"></div> </pre> } </MudPaper> @code { private bool _cleaning; private NotifyCollectionChangedEventHandler? _logsChangedHandler; private bool _jsReady; public void Dispose() { if (_logsChangedHandler is not null) LogWindowViewModel.Logs.CollectionChanged -= _logsChangedHandler; } protected override Task OnParametersSetAsync() { _logsChangedHandler ??= async void (_, args) => { try { if (_cleaning || !_jsReady) return; if (args is { Action: NotifyCollectionChangedAction.Add, NewItems: not null }) { var lines = args.NewItems.Cast<string?>().Where(s => s is not null).Cast<string>().ToArray(); if (lines.Length > 0) await JSRuntime.InvokeVoidAsync("logsView.appendLines", "logsCode", lines, 250); } else { await JSRuntime.InvokeVoidAsync("logsView.setLogs", "logsCode", LogWindowViewModel.GetLogsTextSnapshot()); } } catch (Exception e) { Logger.LogError(e, "Error updating logs"); } }; LogWindowViewModel.Logs.CollectionChanged += _logsChangedHandler; return base.OnParametersSetAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _jsReady = true; await JSRuntime.InvokeVoidAsync("logsView.setLogs", "logsCode", LogWindowViewModel.GetLogsTextSnapshot()); } await base.OnAfterRenderAsync(firstRender); } /// <summary> /// Clears logs and the related view. /// Uses a workaround since PrismJS modifies the DOM directly which means Blazor cannot remove the logs by itself. /// </summary> public async Task Clear() { _cleaning = true; await InvokeAsync(StateHasChanged); LogWindowViewModel.ClearLogs(); await Task.Delay(500); _cleaning = false; if (_jsReady) await JSRuntime.InvokeVoidAsync("logsView.setLogs", "logsCode", string.Empty); await InvokeAsync(StateHasChanged); } } Accompanying JS that adds the UI element and runs syntax highlighting on it Displaimer, I used AI to make this part since I suck at JS // AI Generated and reviewed. Unfortunately, there were too many issues with adding new entries to logs via just Blazor's DOM manipulation, so this JS helper is used instead (function () { const ensureElement = (id) => { if (!id) return null; return document.getElementById(id); }; const highlightNow = (el) => { if (!el) return; if (window.Prism && window.Prism.highlightElement) { window.Prism.highlightElement(el); } }; window.logsView = { setLogs: (codeId, text) => { const el = ensureElement(codeId); if (!el) return; el.textContent = text ?? ""; highlightNow(el); }, appendLines: (codeId, lines, highlightDelayMs) => { const el = ensureElement(codeId); if (!el) return; if (!Array.isArray(lines) || lines.length === 0) return; // Use a fragment to minimize layout work. const frag = document.createDocumentFragment(); for (const line of lines) { frag.appendChild(document.createTextNode((line ?? "") + "\n")); } el.appendChild(frag); // Highlight when new lines are appended (no timer/debounce). highlightNow(el); } }; })();

I would like to contribute the resulting component to a repo so anyone can use it once I get it to work reliably.

Issues:

Bad performance and memory load, tasks for adding entries seem to pile up Freshly added entries "blink" since it takes a moment for highlighting to finish
Read Entire Article