From 8ea9116016991febd8754a69e6ea57d6a9507ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Fri, 19 Dec 2025 11:55:48 +0100 Subject: [PATCH 01/10] feat(export): add log export functionality with filtered entries support - Add ExportParameter model with ExportFormat enum - Add ExportCommand to NLogViewer for exporting filtered log entries - Add ExportLogsCommand to MainViewModel with SaveFileDialog integration - Add Logs -> Export menu item with automatic enable/disable based on tab selection - Add localization resources for export menu items in all supported languages - Export format: yyyy-MM-dd HH:mm:ss.ffff | LEVEL | LoggerName | Message - Default filename: {TabHeader}-{Timestamp}.log --- app/Sentinel.NLogViewer.App/MainWindow.xaml | 6 + .../Resources/Resources.de.resx | 9 ++ .../Resources/Resources.en.resx | 9 ++ .../Resources/Resources.es.resx | 9 ++ .../Resources/Resources.fr.resx | 9 ++ .../Resources/Resources.hu.resx | 9 ++ .../Resources/Resources.pl.resx | 9 ++ .../Resources/Resources.resx | 9 ++ .../Resources/Resources.ro.resx | 9 ++ .../ViewModels/MainViewModel.cs | 149 ++++++++++++++++++ .../Models/ExportParameter.cs | 29 ++++ ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs | 97 ++++++++++++ 12 files changed, 353 insertions(+) create mode 100644 ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs diff --git a/app/Sentinel.NLogViewer.App/MainWindow.xaml b/app/Sentinel.NLogViewer.App/MainWindow.xaml index 9296edf..988e6a9 100644 --- a/app/Sentinel.NLogViewer.App/MainWindow.xaml +++ b/app/Sentinel.NLogViewer.App/MainWindow.xaml @@ -43,6 +43,12 @@ Command="{Binding OpenSettingsCommand}" InputGestureText="Ctrl+,"/> + + + + + diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.de.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.de.resx index 2afabeb..9dbe962 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.de.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.de.resx @@ -79,6 +79,15 @@ _Über... + + _Protokolle + + + _Exportieren + + + *.log Format + Lauschen starten diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.en.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.en.resx index 2d04605..13446d5 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.en.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.en.resx @@ -79,6 +79,15 @@ _About... + + _Logs + + + _Export + + + *.log format + Start Listening diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.es.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.es.resx index 08c0458..f195c24 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.es.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.es.resx @@ -79,6 +79,15 @@ _Acerca de... + + _Registros + + + _Exportar + + + formato *.log + Iniciar escucha diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.fr.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.fr.resx index 305b93b..0cda2ad 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.fr.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.fr.resx @@ -79,6 +79,15 @@ À _propos... + + _Journaux + + + _Exporter + + + format *.log + Démarrer l'écoute diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.hu.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.hu.resx index 9712e05..11c76b0 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.hu.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.hu.resx @@ -79,6 +79,15 @@ _Névjegy... + + _Naplók + + + _Exportálás + + + *.log formátum + Figyelés indítása diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.pl.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.pl.resx index 472a3a0..57285f6 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.pl.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.pl.resx @@ -79,6 +79,15 @@ _O programie... + + _Logi + + + _Eksportuj + + + format *.log + Rozpocznij nasłuchiwanie diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.resx index 2d04605..13446d5 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.resx @@ -79,6 +79,15 @@ _About... + + _Logs + + + _Export + + + *.log format + Start Listening diff --git a/app/Sentinel.NLogViewer.App/Resources/Resources.ro.resx b/app/Sentinel.NLogViewer.App/Resources/Resources.ro.resx index adee716..a51be71 100644 --- a/app/Sentinel.NLogViewer.App/Resources/Resources.ro.resx +++ b/app/Sentinel.NLogViewer.App/Resources/Resources.ro.resx @@ -79,6 +79,15 @@ _Despre... + + _Jurnale + + + _Exportă + + + format *.log + Începe ascultarea diff --git a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs index 678827f..49e78f3 100644 --- a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs +++ b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs @@ -11,6 +11,9 @@ using System.Threading.Tasks; using System.Windows.Input; using System.Windows.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; using Sentinel.NLogViewer.Wpf; using Microsoft.Extensions.DependencyInjection; using Microsoft.Win32; @@ -65,6 +68,7 @@ public MainViewModel( ExitCommand = new RelayCommand(() => System.Windows.Application.Current.Shutdown()); AboutCommand = new RelayCommand(ShowAbout); ChangeLanguageCommand = new RelayCommand(ChangeLanguage); + ExportLogsCommand = new RelayCommand(ExportLogs, () => SelectedTab != null); // Initialize current language flag UpdateLanguageFlag(); @@ -137,6 +141,8 @@ public LogTabViewModel? SelectedTab { _selectedTab = value; OnPropertyChanged(); + // Update ExportLogsCommand CanExecute + ((RelayCommand)ExportLogsCommand).RaiseCanExecuteChanged(); } } } @@ -202,6 +208,7 @@ public string LastLogTimestamp public ICommand ExitCommand { get; } public ICommand AboutCommand { get; } public ICommand ChangeLanguageCommand { get; } + public ICommand ExportLogsCommand { get; } /// /// Gets the flag emoji for the current language @@ -584,6 +591,148 @@ private void ChangeLanguage(string? languageCode) } } + /// + /// Exports filtered log entries from the currently selected tab to a file + /// + private void ExportLogs() + { + if (SelectedTab == null) + return; + + // Generate default filename from tab header (remove whitespaces) + timestamp + var headerWithoutSpaces = SelectedTab.Header?.Replace(" ", string.Empty) ?? "Logs"; + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var defaultFileName = $"{headerWithoutSpaces}-{timestamp}.log"; + + var dialog = new SaveFileDialog + { + Filter = "Log Files (*.log)|*.log|All Files (*.*)|*.*", + Title = "Export Logs", + DefaultExt = "log", + FileName = defaultFileName + }; + + if (dialog.ShowDialog() == true) + { + var filePath = dialog.FileName; + var nLogViewer = FindNLogViewerInTab(); + + if (nLogViewer == null) + { + StatusMessage = "Could not find NLogViewer instance in selected tab."; + return; + } + + var exportParameter = new ExportParameter + { + FilePath = filePath, + Format = ExportFormat.Log + }; + + // Execute the export command on the NLogViewer + if (nLogViewer.ExportCommand?.CanExecute(exportParameter) == true) + { + nLogViewer.ExportCommand.Execute(exportParameter); + StatusMessage = $"Exported logs to {Path.GetFileName(filePath)}"; + } + else + { + StatusMessage = "Export command is not available."; + } + } + } + + /// + /// Finds the NLogViewer instance in the currently selected tab's visual tree + /// + /// The NLogViewer instance if found, null otherwise + private Wpf.NLogViewer? FindNLogViewerInTab() + { + if (SelectedTab == null) + return null; + + // Get the main window to access the TabControl + var mainWindow = System.Windows.Application.Current.MainWindow; + if (mainWindow == null) + return null; + + // Find the TabControl in the main window + var tabControl = FindVisualChild(mainWindow); + if (tabControl == null) + return null; + + // The TabControl uses TabContent attached property which caches content in a Border + // Search for Border elements that might contain the tab content + var borders = FindVisualChildren(tabControl); + + foreach (var border in borders) + { + // Check if this border contains a ContentControl (which is used by TabContent) + var contentControl = FindVisualChild(border); + if (contentControl != null && contentControl.DataContext == SelectedTab) + { + // Found the content control for the selected tab, now find NLogViewer + var nLogViewer = FindVisualChild(contentControl); + if (nLogViewer != null) + return nLogViewer; + } + } + + // Fallback: Search directly in TabControl for NLogViewer + return FindVisualChild(tabControl); + } + + /// + /// Recursively searches the visual tree for all child elements of the specified type + /// + /// The type of child elements to find + /// The parent element to search in + /// An enumerable collection of found child elements + private static IEnumerable FindVisualChildren(DependencyObject parent) where T : DependencyObject + { + if (parent == null) + yield break; + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + if (child is T result) + yield return result; + + foreach (var childOfType in FindVisualChildren(child)) + { + yield return childOfType; + } + } + } + + /// + /// Recursively searches the visual tree for a child element of the specified type + /// + /// The type of child element to find + /// The parent element to search in + /// The found child element, or null if not found + private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject + { + if (parent == null) + return null; + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + if (child is T result) + return result; + + var childOfType = FindVisualChild(child); + if (childOfType != null) + return childOfType; + } + + return null; + } + private void UpdateLanguageFlag() { CurrentLanguageFlag = _localizationService.CurrentLanguageFlag; diff --git a/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs b/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs new file mode 100644 index 0000000..79cdf9a --- /dev/null +++ b/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs @@ -0,0 +1,29 @@ +namespace Sentinel.NLogViewer.App.Models; + +/// +/// Represents the export format options for log export functionality +/// +public enum ExportFormat +{ + /// + /// Standard log file format (*.log) + /// + Log +} + +/// +/// Parameters for exporting log entries to a file +/// +public class ExportParameter +{ + /// + /// Gets or sets the target file path for the export + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the export format to use + /// + public ExportFormat Format { get; set; } = ExportFormat.Log; +} + diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index 8e86002..f1ee128 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -18,6 +18,9 @@ using Sentinel.NLogViewer.Wpf.Resolver; using Sentinel.NLogViewer.Wpf.Targets; using NLog; +using System.IO; +using System.Collections.Generic; +using Sentinel.NLogViewer.App.Models; namespace Sentinel.NLogViewer.Wpf { @@ -825,6 +828,24 @@ public ICommand CopyToClipboardCommand /// public static readonly DependencyProperty CopyToClipboardCommandProperty = DependencyProperty.Register(nameof(CopyToClipboardCommand), typeof(ICommand), typeof(NLogViewer), new PropertyMetadata(null)); + + /// + /// Command to export filtered log entries to a file + /// + [Category("NLogViewerControls")] + [Browsable(true)] + [Description("Command to export filtered log entries to a file")] + public ICommand ExportCommand + { + get => (ICommand) GetValue(ExportCommandProperty); + set => SetValue(ExportCommandProperty, value); + } + + /// + /// The DependencyProperty. + /// + public static readonly DependencyProperty ExportCommandProperty = DependencyProperty.Register(nameof(ExportCommand), + typeof(ICommand), typeof(NLogViewer), new PropertyMetadata(null)); /// /// Stop logging @@ -1464,6 +1485,81 @@ public void CopyToClipboard(string text) } } + /// + /// Gets all filtered log entries from the current view + /// + /// An enumerable collection of filtered LogEventInfo entries + public IEnumerable GetFilteredLogEntries() + { + if (LogEvents?.View == null) + return Enumerable.Empty(); + + return LogEvents.View.Cast(); + } + + /// + /// Exports filtered log entries to a file in the specified format + /// + /// Export parameters containing file path and format + public void ExportLogs(ExportParameter parameter) + { + if (parameter == null) + throw new ArgumentNullException(nameof(parameter)); + + if (string.IsNullOrWhiteSpace(parameter.FilePath)) + throw new ArgumentException("File path cannot be empty", nameof(parameter)); + + try + { + var filteredEntries = GetFilteredLogEntries().ToList(); + + if (filteredEntries.Count == 0) + { + MessageBox.Show("No log entries to export.", "Export", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + switch (parameter.Format) + { + case ExportFormat.Log: + ExportToLogFormat(parameter.FilePath, filteredEntries); + break; + default: + throw new NotSupportedException($"Export format {parameter.Format} is not supported."); + } + + MessageBox.Show($"Successfully exported {filteredEntries.Count} log entry(ies) to {parameter.FilePath}", + "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to export logs: {ex.Message}", "Export Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /// + /// Exports log entries to a standard log file format + /// + /// Target file path + /// Log entries to export + private void ExportToLogFormat(string filePath, IEnumerable logEntries) + { + using var writer = new StreamWriter(filePath, false, System.Text.Encoding.UTF8); + + foreach (var logEvent in logEntries) + { + // Format: yyyy-MM-dd HH:mm:ss.ffff | LEVEL | LoggerName | Message + var timestamp = logEvent.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss.ffff"); + var level = logEvent.Level.Name.ToUpper(); + var loggerName = LoggerNameResolver?.Resolve(logEvent) ?? logEvent.LoggerName ?? "Unknown"; + var message = MessageResolver?.Resolve(logEvent) ?? logEvent.FormattedMessage ?? string.Empty; + + writer.WriteLine($"{timestamp} | {level} | {loggerName} | {message}"); + } + } + #endregion // ########################################################################################## @@ -1711,6 +1807,7 @@ public NLogViewer() AddRegexSearchTermExcludeCommand = new RelayCommand(AddRegexSearchTermExclude); EditSearchTermCommand = new RelayCommand(EditSearchTerm); CopyToClipboardCommand = new RelayCommand(CopyToClipboard); + ExportCommand = new RelayCommand(ExportLogs); // Filter commands are no longer needed - ToggleButtons handle the binding directly } From 92403b87723b5522204a5b0ed1201e6a2a8c2502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Fri, 19 Dec 2025 12:45:33 +0100 Subject: [PATCH 02/10] refactor(export): introduce formatter interface for extensible log export formatting --- .../Models/ExportParameter.cs | 7 ++++ ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs | 37 ++++++++++++++----- .../Resolver/DefaultLogExportFormatter.cs | 35 ++++++++++++++++++ .../Resolver/ILogExportFormatter.cs | 25 +++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs create mode 100644 ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs diff --git a/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs b/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs index 79cdf9a..936c464 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs @@ -1,3 +1,5 @@ +using Sentinel.NLogViewer.Wpf.Resolver; + namespace Sentinel.NLogViewer.App.Models; /// @@ -25,5 +27,10 @@ public class ExportParameter /// Gets or sets the export format to use /// public ExportFormat Format { get; set; } = ExportFormat.Log; + + /// + /// Gets or sets an optional custom formatter to override the default export formatter for this export operation + /// + public ILogExportFormatter? CustomFormatter { get; set; } } diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index f1ee128..3eb20b6 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -970,6 +970,21 @@ public ILogEventInfoResolver MessageResolver /// The DependencyProperty. /// public static readonly DependencyProperty MessageResolverProperty = DependencyProperty.Register(nameof(MessageResolver), typeof(ILogEventInfoResolver), typeof(NLogViewer), new PropertyMetadata(new MessageResolver())); + + /// + /// The to format log entries for export + /// + [Category("NLogViewerResolver")] + public ILogExportFormatter ExportFormatter + { + get => (ILogExportFormatter)GetValue(ExportFormatterProperty); + set => SetValue(ExportFormatterProperty, value); + } + + /// + /// The DependencyProperty. + /// + public static readonly DependencyProperty ExportFormatterProperty = DependencyProperty.Register(nameof(ExportFormatter), typeof(ILogExportFormatter), typeof(NLogViewer), new PropertyMetadata(new DefaultLogExportFormatter())); #endregion @@ -1523,7 +1538,7 @@ public void ExportLogs(ExportParameter parameter) switch (parameter.Format) { case ExportFormat.Log: - ExportToLogFormat(parameter.FilePath, filteredEntries); + ExportToLogFormat(parameter.FilePath, filteredEntries, parameter.CustomFormatter); break; default: throw new NotSupportedException($"Export format {parameter.Format} is not supported."); @@ -1544,19 +1559,23 @@ public void ExportLogs(ExportParameter parameter) /// /// Target file path /// Log entries to export - private void ExportToLogFormat(string filePath, IEnumerable logEntries) + /// Optional custom formatter to override the default formatter + private void ExportToLogFormat(string filePath, IEnumerable logEntries, ILogExportFormatter? customFormatter = null) { using var writer = new StreamWriter(filePath, false, System.Text.Encoding.UTF8); + // Use custom formatter if provided, otherwise use ExportFormatter, fall back to default formatter + var formatter = customFormatter ?? ExportFormatter ?? new DefaultLogExportFormatter(); + foreach (var logEvent in logEntries) { - // Format: yyyy-MM-dd HH:mm:ss.ffff | LEVEL | LoggerName | Message - var timestamp = logEvent.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss.ffff"); - var level = logEvent.Level.Name.ToUpper(); - var loggerName = LoggerNameResolver?.Resolve(logEvent) ?? logEvent.LoggerName ?? "Unknown"; - var message = MessageResolver?.Resolve(logEvent) ?? logEvent.FormattedMessage ?? string.Empty; - - writer.WriteLine($"{timestamp} | {level} | {loggerName} | {message}"); + var formattedLine = formatter.Format( + logEvent, + TimeStampResolver ?? new TimeStampResolver(), + LoggerNameResolver ?? new LoggerNameResolver(), + MessageResolver ?? new MessageResolver()); + + writer.WriteLine(formattedLine); } } diff --git a/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs b/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs new file mode 100644 index 0000000..c3b44a8 --- /dev/null +++ b/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs @@ -0,0 +1,35 @@ +using NLog; + +namespace Sentinel.NLogViewer.Wpf.Resolver +{ + /// + /// Default implementation of ILogExportFormatter that formats log entries + /// in the standard format: yyyy-MM-dd HH:mm:ss.ffff | LEVEL | LoggerName | Message + /// + public class DefaultLogExportFormatter : ILogExportFormatter + { + /// + /// Formats a log event info into a string representation for export + /// + /// The log event to format + /// Resolver for timestamp formatting + /// Resolver for logger name formatting + /// Resolver for message formatting + /// The formatted log entry as a string + public string Format( + LogEventInfo logEventInfo, + ILogEventInfoResolver timeStampResolver, + ILogEventInfoResolver loggerNameResolver, + ILogEventInfoResolver messageResolver) + { + // Format: yyyy-MM-dd HH:mm:ss.ffff | LEVEL | LoggerName | Message + var timestamp = logEventInfo.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss.ffff"); + var level = logEventInfo.Level.Name.ToUpper(); + var loggerName = loggerNameResolver?.Resolve(logEventInfo) ?? logEventInfo.LoggerName ?? "Unknown"; + var message = messageResolver?.Resolve(logEventInfo) ?? logEventInfo.FormattedMessage ?? string.Empty; + + return $"{timestamp} | {level} | {loggerName} | {message}"; + } + } +} + diff --git a/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs b/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs new file mode 100644 index 0000000..319faaf --- /dev/null +++ b/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs @@ -0,0 +1,25 @@ +using NLog; + +namespace Sentinel.NLogViewer.Wpf.Resolver +{ + /// + /// Interface for formatting log entries for export + /// + public interface ILogExportFormatter + { + /// + /// Formats a log event info into a string representation for export + /// + /// The log event to format + /// Resolver for timestamp formatting + /// Resolver for logger name formatting + /// Resolver for message formatting + /// The formatted log entry as a string + string Format( + LogEventInfo logEventInfo, + ILogEventInfoResolver timeStampResolver, + ILogEventInfoResolver loggerNameResolver, + ILogEventInfoResolver messageResolver); + } +} + From 3e9fbbce22364e2bff94dd065e182e1b331dafa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Fri, 19 Dec 2025 13:08:27 +0100 Subject: [PATCH 03/10] feat(parser): add pipe-separated format and multi-line message support - Add support for pipe-separated log format (timestamp | level | logger | message) - Add multi-line message support with continuation line detection - Add ISO 8601 UTC timestamp formats with 'Z' suffix - Extend all tests to verify TimeStamp, LogLevel, LoggerName, and Message --- .../Parsers/PlainTextParser.cs | 180 ++++++++- .../Parsers/PlainTextParserTests.cs | 370 ++++++++++++++++-- 2 files changed, 507 insertions(+), 43 deletions(-) diff --git a/app/Sentinel.NLogViewer.App/Parsers/PlainTextParser.cs b/app/Sentinel.NLogViewer.App/Parsers/PlainTextParser.cs index b33911d..73acbbf 100644 --- a/app/Sentinel.NLogViewer.App/Parsers/PlainTextParser.cs +++ b/app/Sentinel.NLogViewer.App/Parsers/PlainTextParser.cs @@ -46,22 +46,65 @@ public List Parse(string[] lines, TextFileFormat? format) public List Parse(string[] lines) { var results = new List(); + LogEventInfo? currentEvent = null; foreach (var line in lines) { - if (string.IsNullOrWhiteSpace(line)) - continue; + // Check if this line starts a new log entry (has a timestamp at the beginning) + var isNewEntry = IsNewLogEntry(line); - var logEvent = ParseLine(line); - if (logEvent != null) + if (isNewEntry) { - results.Add(logEvent); + // Save previous event if exists + if (currentEvent != null) + { + results.Add(currentEvent); + } + + // Parse new log entry + currentEvent = ParseLine(line); + } + else if (currentEvent != null) + { + // This is a continuation line - append to current message + if (string.IsNullOrWhiteSpace(line)) + { + // Preserve empty lines in multi-line messages (e.g., stack traces) + currentEvent.Message += Environment.NewLine; + } + else + { + currentEvent.Message += Environment.NewLine + line; + } } + // If no current event and line doesn't start new entry, skip it (orphaned continuation line) + } + + // Add the last event if exists + if (currentEvent != null) + { + results.Add(currentEvent); } return results; } + /// + /// Determines if a line starts a new log entry by checking for timestamp pattern at the beginning + /// + private bool IsNewLogEntry(string line) + { + if (string.IsNullOrWhiteSpace(line)) + return false; + + // Check if line starts with a timestamp pattern + var trimmedLine = line.TrimStart(); + var timestampMatch = _timestampRegex.Match(trimmedLine); + + // Timestamp must be at the start of the line (after trimming whitespace) + return timestampMatch.Success && timestampMatch.Index == 0; + } + /// /// Parses lines using the format configuration /// @@ -69,22 +112,72 @@ private List ParseWithFormat(string[] lines, TextFileFormat format { var results = new List(); var dataLines = lines.Skip(format.StartLineIndex).ToList(); + LogEventInfo? currentEvent = null; foreach (var line in dataLines) { - if (string.IsNullOrWhiteSpace(line)) - continue; + // Check if this line starts a new log entry + var isNewEntry = IsNewLogEntryWithFormat(line, format); + + if (isNewEntry) + { + // Save previous event if exists + if (currentEvent != null) + { + results.Add(currentEvent); + } - var logEvent = ParseLineWithFormat(line, format); - if (logEvent != null) + // Parse new log entry + currentEvent = ParseLineWithFormat(line, format); + } + else if (currentEvent != null) { - results.Add(logEvent); + // This is a continuation line - append to current message + if (string.IsNullOrWhiteSpace(line)) + { + // Preserve empty lines in multi-line messages (e.g., stack traces) + currentEvent.Message += Environment.NewLine; + } + else + { + currentEvent.Message += Environment.NewLine + line; + } } + // If no current event and line doesn't start new entry, skip it (orphaned continuation line) + } + + // Add the last event if exists + if (currentEvent != null) + { + results.Add(currentEvent); } return results; } + /// + /// Determines if a line starts a new log entry when using format-based parsing + /// + private bool IsNewLogEntryWithFormat(string line, TextFileFormat format) + { + if (string.IsNullOrWhiteSpace(line)) + return false; + + // If timestamp column is mapped, check if first column matches timestamp pattern + if (format.ColumnMapping.TimestampColumn >= 0) + { + var parts = SplitLine(line, format.Separator); + if (format.ColumnMapping.TimestampColumn < parts.Length) + { + var timestampStr = parts[format.ColumnMapping.TimestampColumn].Trim(); + return TryParseTimestamp(timestampStr, out _); + } + } + + // Fallback to general timestamp detection + return IsNewLogEntry(line); + } + /// /// Parses a single line using the format configuration /// @@ -166,6 +259,12 @@ private string[] SplitLine(string line, string separator) { try { + // Check if line uses pipe-separated format + if (line.Contains(" | ")) + { + return ParsePipeSeparatedLine(line); + } + // Try to extract timestamp var timestampMatch = _timestampRegex.Match(line); var timestamp = timestampMatch.Success && TryParseTimestamp(timestampMatch.Value, out var dt) @@ -200,6 +299,50 @@ private string[] SplitLine(string line, string separator) } } + /// + /// Parses a pipe-separated log line: timestamp | level | logger | message + /// + private LogEventInfo? ParsePipeSeparatedLine(string line) + { + try + { + var parts = line.Split(new[] { " | " }, StringSplitOptions.None); + + if (parts.Length < 4) + { + // Not enough parts for pipe format, fall back to regular parsing + return null; + } + + // Extract timestamp (first part) + var timestampStr = parts[0].Trim(); + var timestamp = TryParseTimestamp(timestampStr, out var dt) ? dt : DateTime.Now; + + // Extract level (second part) + var levelStr = parts[1].Trim(); + var level = ParseLogLevel(levelStr); + + // Extract logger (third part) + var loggerName = parts[2].Trim(); + if (string.IsNullOrWhiteSpace(loggerName)) + { + loggerName = "Unknown"; + } + + // Extract message (fourth part and beyond, in case message contains " | ") + var message = string.Join(" | ", parts.Skip(3)).Trim(); + + return new LogEventInfo(level, loggerName, message) + { + TimeStamp = timestamp + }; + } + catch + { + return null; + } + } + /// /// Attempts to parse a timestamp string using various common formats /// @@ -219,6 +362,9 @@ private bool TryParseTimestamp(string timestampStr, out DateTime result) "yyyy-MM-ddTHH:mm:ss.ffff", // ISO 8601 with microseconds "yyyy-MM-ddTHH:mm:ss.fff", // ISO 8601 with milliseconds "yyyy-MM-ddTHH:mm:ss", // ISO 8601 + "yyyy-MM-ddTHH:mm:ss.ffffZ", // ISO 8601 with microseconds and UTC (Z) + "yyyy-MM-ddTHH:mm:ss.fffZ", // ISO 8601 with milliseconds and UTC (Z) + "yyyy-MM-ddTHH:mm:ssZ", // ISO 8601 with UTC (Z) "yyyy-MM-dd HH:mm:ss.ffffK", // With timezone "yyyy-MM-dd HH:mm:ss.fffK", // With timezone "yyyy-MM-dd HH:mm:ssK" // With timezone @@ -227,14 +373,24 @@ private bool TryParseTimestamp(string timestampStr, out DateTime result) // Try parsing with exact formats first (using InvariantCulture to avoid locale issues) foreach (var format in formats) { - if (DateTime.TryParseExact(timestampStr, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)) + // Use RoundtripKind for formats with 'Z' to preserve UTC + var styles = format.EndsWith("Z") + ? DateTimeStyles.RoundtripKind + : DateTimeStyles.None; + + if (DateTime.TryParseExact(timestampStr, format, CultureInfo.InvariantCulture, styles, out result)) { return true; } } // Fallback: Try parsing with InvariantCulture (handles ISO formats better) - if (DateTime.TryParse(timestampStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out result)) + // Use RoundtripKind to preserve UTC for timestamps ending with 'Z' + var fallbackStyles = timestampStr.EndsWith("Z", StringComparison.OrdinalIgnoreCase) + ? DateTimeStyles.RoundtripKind + : DateTimeStyles.None; + + if (DateTime.TryParse(timestampStr, CultureInfo.InvariantCulture, fallbackStyles, out result)) { return true; } diff --git a/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs index 5a98887..5150cc1 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs @@ -33,9 +33,15 @@ public void Parse_LineWithTimestampAndLevel_ParsesCorrectly() // Assert Assert.Single(results); var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); Assert.Equal(LogLevel.Info, logEvent.Level); Assert.Equal("MyLogger", logEvent.LoggerName); - Assert.Contains("Application started", logEvent.Message); + Assert.Contains("Application started successfully", logEvent.Message); } [Fact] @@ -57,12 +63,47 @@ public void Parse_LineWithDifferentLevels_ParsesLevelsCorrectly() // Assert Assert.Equal(6, results.Count); + + // First entry + Assert.Equal(2024, results[0].TimeStamp.Year); + Assert.Equal(1, results[0].TimeStamp.Month); + Assert.Equal(15, results[0].TimeStamp.Day); + Assert.Equal(10, results[0].TimeStamp.Hour); + Assert.Equal(30, results[0].TimeStamp.Minute); + Assert.Equal(45, results[0].TimeStamp.Second); Assert.Equal(LogLevel.Trace, results[0].Level); + Assert.Equal("Logger", results[0].LoggerName); + Assert.Contains("Trace message", results[0].Message); + + // Second entry + Assert.Equal(46, results[1].TimeStamp.Second); Assert.Equal(LogLevel.Debug, results[1].Level); + Assert.Equal("Logger", results[1].LoggerName); + Assert.Contains("Debug message", results[1].Message); + + // Third entry + Assert.Equal(47, results[2].TimeStamp.Second); Assert.Equal(LogLevel.Info, results[2].Level); + Assert.Equal("Logger", results[2].LoggerName); + Assert.Contains("Info message", results[2].Message); + + // Fourth entry + Assert.Equal(48, results[3].TimeStamp.Second); Assert.Equal(LogLevel.Warn, results[3].Level); + Assert.Equal("Logger", results[3].LoggerName); + Assert.Contains("Warn message", results[3].Message); + + // Fifth entry + Assert.Equal(49, results[4].TimeStamp.Second); Assert.Equal(LogLevel.Error, results[4].Level); + Assert.Equal("Logger", results[4].LoggerName); + Assert.Contains("Error message", results[4].Message); + + // Sixth entry + Assert.Equal(50, results[5].TimeStamp.Second); Assert.Equal(LogLevel.Fatal, results[5].Level); + Assert.Equal("Logger", results[5].LoggerName); + Assert.Contains("Fatal message", results[5].Message); } [Fact] @@ -80,30 +121,18 @@ public void Parse_LineWithIsoTimestamp_ParsesTimestamp() // Assert Assert.Single(results); var logEvent = results[0]; - Assert.True(logEvent.TimeStamp.Year == 2024); - Assert.True(logEvent.TimeStamp.Month == 1); - Assert.True(logEvent.TimeStamp.Day == 15); - } - - [Fact] - public void Parse_LineWithoutTimestamp_UsesCurrentTime() - { - // Arrange - var before = DateTime.Now; - var lines = new[] - { - "INFO [Logger] Message without timestamp" - }; - - // Act - var results = _parser.Parse(lines); - var after = DateTime.Now; - - // Assert - Assert.Single(results); - var logEvent = results[0]; - Assert.True(logEvent.TimeStamp >= before.AddSeconds(-1)); - Assert.True(logEvent.TimeStamp <= after.AddSeconds(1)); + + // ISO timestamp with 'Z' should be parsed as UTC + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + + Assert.Equal(LogLevel.Info, logEvent.Level); + Assert.Equal("Logger", logEvent.LoggerName); + Assert.Contains("Message with ISO timestamp", logEvent.Message); } [Fact] @@ -120,7 +149,16 @@ public void Parse_LineWithoutLevel_DefaultsToInfo() // Assert Assert.Single(results); - Assert.Equal(LogLevel.Info, results[0].Level); + var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Info, logEvent.Level); + Assert.Equal("Logger", logEvent.LoggerName); + Assert.Contains("Message without level", logEvent.Message); } [Fact] @@ -137,7 +175,16 @@ public void Parse_LineWithLoggerInBrackets_ExtractsLoggerName() // Assert Assert.Single(results); - Assert.Equal("MyApp.Services.DataService", results[0].LoggerName); + var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Info, logEvent.Level); + Assert.Equal("MyApp.Services.DataService", logEvent.LoggerName); + Assert.Contains("Processing request", logEvent.Message); } [Fact] @@ -154,7 +201,16 @@ public void Parse_LineWithLoggerInParentheses_ExtractsLoggerName() // Assert Assert.Single(results); - Assert.Equal("MyApp.Controllers.HomeController", results[0].LoggerName); + var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Info, logEvent.Level); + Assert.Equal("MyApp.Controllers.HomeController", logEvent.LoggerName); + Assert.Contains("Processing request", logEvent.Message); } [Fact] @@ -171,7 +227,16 @@ public void Parse_LineWithoutLogger_DefaultsToUnknown() // Assert Assert.Single(results); - Assert.Equal("Unknown", results[0].LoggerName); + var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Info, logEvent.Level); + Assert.Equal("Unknown", logEvent.LoggerName); + Assert.Contains("Simple log message", logEvent.Message); } [Fact] @@ -192,7 +257,16 @@ public void Parse_EmptyLines_IgnoresEmptyLines() // Assert Assert.Single(results); - Assert.Contains("Valid message", results[0].Message); + var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Info, logEvent.Level); + Assert.Equal("Logger", logEvent.LoggerName); + Assert.Contains("Valid message", logEvent.Message); } [Fact] @@ -211,9 +285,29 @@ public void Parse_MultipleLines_ParsesAllLines() // Assert Assert.Equal(3, results.Count); + + // First entry + Assert.Equal(2024, results[0].TimeStamp.Year); + Assert.Equal(1, results[0].TimeStamp.Month); + Assert.Equal(15, results[0].TimeStamp.Day); + Assert.Equal(10, results[0].TimeStamp.Hour); + Assert.Equal(30, results[0].TimeStamp.Minute); + Assert.Equal(45, results[0].TimeStamp.Second); + Assert.Equal(LogLevel.Info, results[0].Level); Assert.Equal("Logger1", results[0].LoggerName); + Assert.Contains("First message", results[0].Message); + + // Second entry + Assert.Equal(46, results[1].TimeStamp.Second); + Assert.Equal(LogLevel.Warn, results[1].Level); Assert.Equal("Logger2", results[1].LoggerName); + Assert.Contains("Second message", results[1].Message); + + // Third entry + Assert.Equal(47, results[2].TimeStamp.Second); + Assert.Equal(LogLevel.Error, results[2].Level); Assert.Equal("Logger3", results[2].LoggerName); + Assert.Contains("Third message", results[2].Message); } [Fact] @@ -230,7 +324,221 @@ public void Parse_LineWithWarningKeyword_ParsesAsWarn() // Assert Assert.Single(results); - Assert.Equal(LogLevel.Warn, results[0].Level); + var logEvent = results[0]; + Assert.Equal(2024, logEvent.TimeStamp.Year); + Assert.Equal(1, logEvent.TimeStamp.Month); + Assert.Equal(15, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(30, logEvent.TimeStamp.Minute); + Assert.Equal(45, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Warn, logEvent.Level); + Assert.Equal("Logger", logEvent.LoggerName); + Assert.Contains("Warning message", logEvent.Message); + } + + [Fact] + public void Parse_PipeSeparatedFormat_ParsesCorrectly() + { + // Arrange + var lines = new[] + { + "2025-12-19 10:58:37.8960 | FATAL | Sentinel.NLogViewer.App.TestApp | Background task completed (Message #735)" + }; + + // Act + var results = _parser.Parse(lines); + + // Assert + Assert.Single(results); + var logEvent = results[0]; + Assert.Equal(2025, logEvent.TimeStamp.Year); + Assert.Equal(12, logEvent.TimeStamp.Month); + Assert.Equal(19, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(58, logEvent.TimeStamp.Minute); + Assert.Equal(37, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Fatal, logEvent.Level); + Assert.Equal("Sentinel.NLogViewer.App.TestApp", logEvent.LoggerName); + Assert.Contains("Background task completed (Message #735)", logEvent.Message); + } + + [Fact] + public void Parse_PipeSeparatedFormatWithMilliseconds_ParsesTimestamp() + { + // Arrange + var lines = new[] + { + "2025-12-19 10:58:37.8960 | FATAL | Logger | Message", + "2025-12-19 10:58:38.8880 | WARN | Logger | Message" + }; + + // Act + var results = _parser.Parse(lines); + + // Assert + Assert.Equal(2, results.Count); + + // First entry + Assert.Equal(2025, results[0].TimeStamp.Year); + Assert.Equal(12, results[0].TimeStamp.Month); + Assert.Equal(19, results[0].TimeStamp.Day); + Assert.Equal(10, results[0].TimeStamp.Hour); + Assert.Equal(58, results[0].TimeStamp.Minute); + Assert.Equal(37, results[0].TimeStamp.Second); + // Milliseconds should be parsed (may be 896 from 8960 or similar) + Assert.True(results[0].TimeStamp.Millisecond >= 0 && results[0].TimeStamp.Millisecond < 1000); + Assert.Equal(LogLevel.Fatal, results[0].Level); + Assert.Equal("Logger", results[0].LoggerName); + Assert.Contains("Message", results[0].Message); + + // Second entry + Assert.Equal(38, results[1].TimeStamp.Second); + Assert.Equal(LogLevel.Warn, results[1].Level); + Assert.Equal("Logger", results[1].LoggerName); + Assert.Contains("Message", results[1].Message); + } + + [Fact] + public void Parse_PipeSeparatedFormatMultipleEntries_ParsesAll() + { + // Arrange + var lines = new[] + { + "2025-12-19 10:58:37.8960 | FATAL | Sentinel.NLogViewer.App.TestApp | Background task completed (Message #735)", + "2025-12-19 10:58:38.8880 | WARN | Sentinel.NLogViewer.App.TestApp.Models | User authentication successful (Message #736)" + }; + + // Act + var results = _parser.Parse(lines); + + // Assert + Assert.Equal(2, results.Count); + + // First entry + Assert.Equal(2025, results[0].TimeStamp.Year); + Assert.Equal(12, results[0].TimeStamp.Month); + Assert.Equal(19, results[0].TimeStamp.Day); + Assert.Equal(10, results[0].TimeStamp.Hour); + Assert.Equal(58, results[0].TimeStamp.Minute); + Assert.Equal(37, results[0].TimeStamp.Second); + Assert.Equal(LogLevel.Fatal, results[0].Level); + Assert.Equal("Sentinel.NLogViewer.App.TestApp", results[0].LoggerName); + Assert.Contains("Background task completed (Message #735)", results[0].Message); + + // Second entry + Assert.Equal(38, results[1].TimeStamp.Second); + Assert.Equal(LogLevel.Warn, results[1].Level); + Assert.Equal("Sentinel.NLogViewer.App.TestApp.Models", results[1].LoggerName); + Assert.Contains("User authentication successful (Message #736)", results[1].Message); + } + + [Fact] + public void Parse_MultiLineMessage_AppendsContinuationLines() + { + // Arrange + var lines = new[] + { + "2024-01-15 10:30:45 ERROR [Logger] First line of error", + "This is a continuation line", + "Another continuation line", + "2024-01-15 10:30:46 INFO [Logger] New log entry" + }; + + // Act + var results = _parser.Parse(lines); + + // Assert + Assert.Equal(2, results.Count); + + // First entry (with continuation lines) + Assert.Equal(2024, results[0].TimeStamp.Year); + Assert.Equal(1, results[0].TimeStamp.Month); + Assert.Equal(15, results[0].TimeStamp.Day); + Assert.Equal(10, results[0].TimeStamp.Hour); + Assert.Equal(30, results[0].TimeStamp.Minute); + Assert.Equal(45, results[0].TimeStamp.Second); + Assert.Equal(LogLevel.Error, results[0].Level); + Assert.Equal("Logger", results[0].LoggerName); + Assert.Contains("First line of error", results[0].Message); + Assert.Contains("This is a continuation line", results[0].Message); + Assert.Contains("Another continuation line", results[0].Message); + Assert.DoesNotContain("New log entry", results[0].Message); + + // Second entry + Assert.Equal(46, results[1].TimeStamp.Second); + Assert.Equal(LogLevel.Info, results[1].Level); + Assert.Equal("Logger", results[1].LoggerName); + Assert.Contains("New log entry", results[1].Message); + } + + [Fact] + public void Parse_MultiLineMessageWithPipeFormat_ParsesCorrectly() + { + // Arrange + var lines = new[] + { + "2025-12-19 10:58:39.8930 | ERROR | Sentinel.NLogViewer.App.TestApp.Controllers.HomeController | Error exception: Invalid operation occurred at 11:58:39", + "", + "System.Exception: System.InvalidOperationException: Invalid operation occurred at 11:58:39" + }; + + // Act + var results = _parser.Parse(lines); + + // Assert + Assert.Single(results); + var logEvent = results[0]; + Assert.Equal(2025, logEvent.TimeStamp.Year); + Assert.Equal(12, logEvent.TimeStamp.Month); + Assert.Equal(19, logEvent.TimeStamp.Day); + Assert.Equal(10, logEvent.TimeStamp.Hour); + Assert.Equal(58, logEvent.TimeStamp.Minute); + Assert.Equal(39, logEvent.TimeStamp.Second); + Assert.Equal(LogLevel.Error, logEvent.Level); + Assert.Equal("Sentinel.NLogViewer.App.TestApp.Controllers.HomeController", logEvent.LoggerName); + Assert.Contains("Error exception: Invalid operation occurred at 11:58:39", logEvent.Message); + Assert.Contains("System.Exception: System.InvalidOperationException: Invalid operation occurred at 11:58:39", logEvent.Message); + } + + [Fact] + public void Parse_MultiLineMessageWithEmptyLines_PreservesStructure() + { + // Arrange + var lines = new[] + { + "2024-01-15 10:30:45 ERROR [Logger] Stack trace:", + "", + " at SomeMethod()", + " at AnotherMethod()", + "", + "2024-01-15 10:30:46 INFO [Logger] Next entry" + }; + + // Act + var results = _parser.Parse(lines); + + // Assert + Assert.Equal(2, results.Count); + + // First entry (with empty lines in message) + Assert.Equal(2024, results[0].TimeStamp.Year); + Assert.Equal(1, results[0].TimeStamp.Month); + Assert.Equal(15, results[0].TimeStamp.Day); + Assert.Equal(10, results[0].TimeStamp.Hour); + Assert.Equal(30, results[0].TimeStamp.Minute); + Assert.Equal(45, results[0].TimeStamp.Second); + Assert.Equal(LogLevel.Error, results[0].Level); + Assert.Equal("Logger", results[0].LoggerName); + Assert.Contains("Stack trace:", results[0].Message); + Assert.Contains("at SomeMethod()", results[0].Message); + Assert.Contains("at AnotherMethod()", results[0].Message); + // Empty lines should be preserved in multi-line messages + + // Second entry + Assert.Equal(46, results[1].TimeStamp.Second); + Assert.Equal(LogLevel.Info, results[1].Level); + Assert.Equal("Logger", results[1].LoggerName); + Assert.Contains("Next entry", results[1].Message); } public void Dispose() From 5018bfe0094d163ca41708047731dc678d9c6977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Thu, 29 Jan 2026 12:53:07 +0100 Subject: [PATCH 04/10] feat(log-viewer): auto-scroll to end when new logs arrive Expose ScrollToEndCommand on NLogViewer, wire NLogViewer reference into LogTabViewModel via DataContext, and trigger scroll after adding log events when AutoScroll is enabled. --- app/Sentinel.NLogViewer.App/MainWindow.xaml | 6 +++- .../Models/LogTabViewModel.cs | 3 ++ .../ViewModels/MainViewModel.cs | 4 +++ ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs | 28 +++++++++++++++++-- .../Resolver/DefaultLogExportFormatter.cs | 1 + .../Resolver/ILogExportFormatter.cs | 1 + 6 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/Sentinel.NLogViewer.App/MainWindow.xaml b/app/Sentinel.NLogViewer.App/MainWindow.xaml index 988e6a9..1c095c2 100644 --- a/app/Sentinel.NLogViewer.App/MainWindow.xaml +++ b/app/Sentinel.NLogViewer.App/MainWindow.xaml @@ -18,7 +18,11 @@ - + diff --git a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs index 48dc8fc..bd4f956 100644 --- a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs +++ b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Windows.Input; using NLog; namespace Sentinel.NLogViewer.App.Models @@ -67,6 +68,8 @@ public int MaxCount } } + public Wpf.NLogViewer NLogViewer { get; set; } + public ObservableCollection LogEventInfos { get; } = new(); public event PropertyChangedEventHandler? PropertyChanged; diff --git a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs index 49e78f3..c332a4c 100644 --- a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs +++ b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs @@ -128,6 +128,10 @@ private void OnLogEvent(IList logEvents) tab.LogCount += logEvents.Count; LastLogTimestamp = DateTime.Now.ToString("HH:mm:ss"); StatusMessage = $"Received {logEvents.Count} log(s) from {firstEvent.AppInfo.AppName.Name}"; + + // scroll to end + if(tab.NLogViewer != null && tab.NLogViewer.AutoScroll) + tab.NLogViewer.ScrollToEndCommand?.Execute(null); } public ObservableCollection LogTabs { get; } diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index 3eb20b6..cae0fd1 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -846,6 +846,28 @@ public ICommand ExportCommand /// public static readonly DependencyProperty ExportCommandProperty = DependencyProperty.Register(nameof(ExportCommand), typeof(ICommand), typeof(NLogViewer), new PropertyMetadata(null)); + + /// + /// Command to scroll to the end of the log entries + /// + [Category("NLogViewerControls")] + [Browsable(true)] + [Description("Command to scroll to the end of the log entries")] + public ICommand ScrollToEndCommand + { + get => (ICommand) GetValue(ScrollToEndCommandProperty); + set => SetValue(ScrollToEndCommandProperty, value); + } + + /// + /// The DependencyProperty. + /// + public static readonly DependencyProperty ScrollToEndCommandProperty = DependencyProperty.Register(nameof(ScrollToEndCommand), + typeof(ICommand), typeof(NLogViewer), new FrameworkPropertyMetadata + { + BindsTwoWayByDefault = true, + DefaultUpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); /// /// Stop logging @@ -1827,8 +1849,10 @@ public NLogViewer() EditSearchTermCommand = new RelayCommand(EditSearchTerm); CopyToClipboardCommand = new RelayCommand(CopyToClipboard); ExportCommand = new RelayCommand(ExportLogs); - - // Filter commands are no longer needed - ToggleButtons handle the binding directly + ScrollToEndCommand = new RelayCommand(() => + { + PART_ListView?.ScrollToEnd(); + }); } private void _OnUnloaded(object sender, RoutedEventArgs e) diff --git a/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs b/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs index c3b44a8..dcc7371 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs @@ -33,3 +33,4 @@ public string Format( } } + diff --git a/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs b/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs index 319faaf..9dc5109 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs @@ -23,3 +23,4 @@ string Format( } } + From 2d2c18fc3f4b4f7536bae2d2c49b5fc9c2f7547d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Thu, 29 Jan 2026 14:43:56 +0100 Subject: [PATCH 05/10] refactor(ui): introduce ICacheTarget and bind NLogViewer via CacheTarget DP - Add ICacheTarget with Cache (IObservable); CacheTarget implements it. - LogTabViewModel implements ICacheTarget, exposes Cache; remove NLogViewer and LogEventInfos. - NLogViewer: add CacheTarget dependency property; OnCacheTargetChanged calls StartListen(target). - StartListen(ICacheTarget?) accepts optional target; fallback to GetInstance(TargetName). - MainViewModel: remove scroll-to-end logic that referenced tab.NLogViewer. --- .../Models/LogTabViewModel.cs | 11 +- .../ViewModels/MainViewModel.cs | 4 - ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs | 37 +++- .../Targets/CacheTarget.cs | 168 +++++++++--------- 4 files changed, 122 insertions(+), 98 deletions(-) diff --git a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs index bd4f956..3ac04e4 100644 --- a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs +++ b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs @@ -1,15 +1,14 @@ -using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; -using System.Windows.Input; using NLog; +using Sentinel.NLogViewer.Wpf.Targets; namespace Sentinel.NLogViewer.App.Models { /// /// ViewModel for a log tab in the TabControl /// - public class LogTabViewModel : INotifyPropertyChanged + public class LogTabViewModel : INotifyPropertyChanged, ICacheTarget { private string _header = string.Empty; private string _targetName = string.Empty; @@ -68,16 +67,14 @@ public int MaxCount } } - public Wpf.NLogViewer NLogViewer { get; set; } - - public ObservableCollection LogEventInfos { get; } = new(); - public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + + public IObservable Cache { get; } } } diff --git a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs index c332a4c..49e78f3 100644 --- a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs +++ b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs @@ -128,10 +128,6 @@ private void OnLogEvent(IList logEvents) tab.LogCount += logEvents.Count; LastLogTimestamp = DateTime.Now.ToString("HH:mm:ss"); StatusMessage = $"Received {logEvents.Count} log(s) from {firstEvent.AppInfo.AppName.Name}"; - - // scroll to end - if(tab.NLogViewer != null && tab.NLogViewer.AutoScroll) - tab.NLogViewer.ScrollToEndCommand?.Execute(null); } public ObservableCollection LogTabs { get; } diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index cae0fd1..09f2d7a 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; @@ -518,7 +518,34 @@ public string TargetName /// The DependencyProperty. /// public static readonly DependencyProperty TargetNameProperty = DependencyProperty.Register(nameof(TargetName), typeof(string), typeof(NLogViewer), new PropertyMetadata(null)); - + + /// + /// Cache target to subscribe to for log events. When set to a non-null value, StartListen is called with this target. + /// + [Category("NLogViewer")] + [Browsable(true)] + [Description("Cache target for log events. When set, the control subscribes via StartListen.")] + public ICacheTarget? CacheTarget + { + get => (ICacheTarget?)GetValue(CacheTargetProperty); + set => SetValue(CacheTargetProperty, value); + } + + /// + /// The DependencyProperty. + /// + public static readonly DependencyProperty CacheTargetProperty = DependencyProperty.Register( + nameof(CacheTarget), + typeof(ICacheTarget), + typeof(NLogViewer), + new PropertyMetadata(null, OnCacheTargetChanged)); + + private static void OnCacheTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is NLogViewer instance && e.NewValue is ICacheTarget target && target != null) + instance.StartListen(target); + } + /// /// Private DP to bind to the gui /// @@ -1728,7 +1755,7 @@ public bool ShowControlButtons /// such as when undocking from a docking system or when the window loads again. /// Note: This method will not start listening if ItemsSource is set (external mode). /// - public void StartListen() + public void StartListen(ICacheTarget? target = null) { // Don't start listening if using external ItemsSource if (ItemsSource != null) @@ -1750,8 +1777,8 @@ public void StartListen() if (_ParentWindow == null) return; - - var target = CacheTarget.GetInstance(targetName: TargetName); + + target ??= Targets.CacheTarget.GetInstance(targetName: TargetName); _Subscription = target.Cache.SubscribeOn(Scheduler.Default) .Buffer(TimeSpan.FromMilliseconds(100)) diff --git a/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs b/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs index e8d303c..8ae9a06 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs @@ -7,88 +7,92 @@ using NLog.Config; using NLog.Targets; -namespace Sentinel.NLogViewer.Wpf.Targets +namespace Sentinel.NLogViewer.Wpf.Targets; + +public interface ICacheTarget +{ + IObservable Cache { get; } +} + +[Target(nameof(CacheTarget))] +public class CacheTarget : Target, ICacheTarget { - [Target(nameof(CacheTarget))] - public class CacheTarget : Target - { - /// - /// Look in the NLog.config if any target is already defined and returns it, otherwise a new one is registered - /// - /// The maximum entries which should be buffered. Is only used if no target is defined - /// The name of the target you want to link with - /// - public static CacheTarget GetInstance(int defaultMaxCount = 0, string? targetName = null) - { - if(LogManager.Configuration == null) - LogManager.Configuration = new LoggingConfiguration(); - - var predicate = PredicateBuilder.True().And(t => t is CacheTarget); - if (!string.IsNullOrEmpty(targetName)) - { - predicate = predicate.And(t => t.Name.Equals(targetName, StringComparison.CurrentCultureIgnoreCase) ||t.Name.Equals($"{targetName}_wrapped", StringComparison.CurrentCultureIgnoreCase)); - } + /// + /// Look in the NLog.config if any target is already defined and returns it, otherwise a new one is registered + /// + /// The maximum entries which should be buffered. Is only used if no target is defined + /// The name of the target you want to link with + /// + public static CacheTarget GetInstance(int defaultMaxCount = 0, string? targetName = null) + { + if(LogManager.Configuration == null) + LogManager.Configuration = new LoggingConfiguration(); + + var predicate = PredicateBuilder.True().And(t => t is CacheTarget); + if (!string.IsNullOrEmpty(targetName)) + { + predicate = predicate.And(t => t.Name.Equals(targetName, StringComparison.CurrentCultureIgnoreCase) ||t.Name.Equals($"{targetName}_wrapped", StringComparison.CurrentCultureIgnoreCase)); + } - var target = (CacheTarget)LogManager.Configuration.AllTargets.FirstOrDefault(predicate.Compile()); - if (target == null) - { - target = new CacheTarget(defaultMaxCount) { Name = targetName ?? nameof(CacheTarget)}; - LogManager.Configuration.AddTarget(target.Name, target); - LogManager.Configuration.LoggingRules.Insert(0, new LoggingRule("*", LogLevel.FromString("Trace"), target)); - LogManager.ReconfigExistingLoggers(); - } - return target; - } - - // ############################################################################################################################## - // Properties - // ############################################################################################################################## - - #region Properties - - // ########################################################################################## - // Public Properties - // ########################################################################################## - - public IObservable Cache => _CacheSubject.AsObservable(); - private readonly ReplaySubject _CacheSubject; - - // ########################################################################################## - // Private Properties - // ########################################################################################## - - #endregion - - // ############################################################################################################################## - // Constructor - // ############################################################################################################################## - - #region Constructor - - /// - /// Initializes a new instance of the CacheTarget class with the specified maximum count - /// - /// The maximum amount of entries held in buffer/cache. Defaults to 100 if not specified. - public CacheTarget(int maxCount = 500) - { - if(maxCount == 0) - maxCount = 500; - _CacheSubject = new ReplaySubject(maxCount); - } - - #endregion - - // ############################################################################################################################## - // override - // ############################################################################################################################## - - #region override - - protected override void Write(LogEventInfo logEvent) - { - _CacheSubject.OnNext(logEvent); - } - - #endregion - } + var target = (CacheTarget)LogManager.Configuration.AllTargets.FirstOrDefault(predicate.Compile()); + if (target == null) + { + target = new CacheTarget(defaultMaxCount) { Name = targetName ?? nameof(CacheTarget)}; + LogManager.Configuration.AddTarget(target.Name, target); + LogManager.Configuration.LoggingRules.Insert(0, new LoggingRule("*", LogLevel.FromString("Trace"), target)); + LogManager.ReconfigExistingLoggers(); + } + return target; + } + + // ############################################################################################################################## + // Properties + // ############################################################################################################################## + + #region Properties + + // ########################################################################################## + // Public Properties + // ########################################################################################## + + public IObservable Cache => _CacheSubject.AsObservable(); + private readonly ReplaySubject _CacheSubject; + + // ########################################################################################## + // Private Properties + // ########################################################################################## + + #endregion + + // ############################################################################################################################## + // Constructor + // ############################################################################################################################## + + #region Constructor + + /// + /// Initializes a new instance of the CacheTarget class with the specified maximum count + /// + /// The maximum amount of entries held in buffer/cache. Defaults to 100 if not specified. + public CacheTarget(int maxCount = 500) + { + if(maxCount == 0) + maxCount = 500; + _CacheSubject = new ReplaySubject(maxCount); + } + + #endregion + + // ############################################################################################################################## + // override + // ############################################################################################################################## + + #region override + + protected override void Write(LogEventInfo logEvent) + { + _CacheSubject.OnNext(logEvent); + } + + #endregion } \ No newline at end of file From b346f5b5e0aa03e5981a1d427dc2a22f3ca24252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Thu, 29 Jan 2026 14:52:15 +0100 Subject: [PATCH 06/10] feat(app): use cache observable for log tab events Implement ReplaySubject in LogTabViewModel so events are held until NLogViewer subscribes. Bind NLogViewer via CacheTarget instead of ItemsSource. Push events via AddLogEvent from MainViewModel. Add System.Reactive to App project. --- app/Sentinel.NLogViewer.App/MainWindow.xaml | 2 +- .../Models/LogTabViewModel.cs | 19 ++++++++++++++++++- .../Sentinel.NLogViewer.App.csproj | 1 + .../ViewModels/MainViewModel.cs | 14 ++++---------- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/app/Sentinel.NLogViewer.App/MainWindow.xaml b/app/Sentinel.NLogViewer.App/MainWindow.xaml index 1c095c2..8e2ab57 100644 --- a/app/Sentinel.NLogViewer.App/MainWindow.xaml +++ b/app/Sentinel.NLogViewer.App/MainWindow.xaml @@ -21,7 +21,7 @@ diff --git a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs index 3ac04e4..5bfea1e 100644 --- a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs +++ b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs @@ -2,6 +2,8 @@ using System.Runtime.CompilerServices; using NLog; using Sentinel.NLogViewer.Wpf.Targets; +using System.Reactive.Linq; +using System.Reactive.Subjects; namespace Sentinel.NLogViewer.App.Models { @@ -74,7 +76,22 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public IObservable Cache { get; } + private readonly ReplaySubject _cacheSubject = new(10000); + + /// + /// Observable stream of log events. Replays buffered events to new subscribers so nothing is lost before subscription. + /// + public IObservable Cache => _cacheSubject.AsObservable(); + + /// + /// Pushes a log event into the cache. Subscribers (e.g. NLogViewer) receive it immediately or via replay when they subscribe later. + /// + /// The log event to add. + public void AddLogEvent(LogEventInfo logEvent) + { + _cacheSubject.OnNext(logEvent); + LogCount++; + } } } diff --git a/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj b/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj index f812b52..0a2aef0 100644 --- a/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj +++ b/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj @@ -35,6 +35,7 @@ + diff --git a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs index 49e78f3..65d5ea9 100644 --- a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs +++ b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs @@ -118,14 +118,11 @@ private void OnLogEvent(IList logEvents) SelectedTab = tab; } - // Add all log events from the batch to the tab + // Add all log events from the batch to the tab (Cache replays to NLogViewer when it subscribes) foreach (var logEvent in logEvents) { - tab.LogEventInfos.Add(logEvent.LogEventInfo); + tab.AddLogEvent(logEvent.LogEventInfo); } - - // Update log count - tab.LogCount += logEvents.Count; LastLogTimestamp = DateTime.Now.ToString("HH:mm:ss"); StatusMessage = $"Received {logEvents.Count} log(s) from {firstEvent.AppInfo.AppName.Name}"; } @@ -529,16 +526,15 @@ private void ProcessParsedLogs(List logEvents, string fileName) SelectedTab = tab; } - // Add all log events in batches to avoid UI freezing + // Add all log events in batches to avoid UI freezing (Cache replays to NLogViewer when it subscribes) const int batchSize = 500; for (int i = 0; i < logEvents.Count; i += batchSize) { var batch = logEvents.Skip(i).Take(batchSize).ToList(); - // Add batch directly to the collection foreach (var logEvent in batch) { - tab.LogEventInfos.Add(logEvent); + tab.AddLogEvent(logEvent); } // Update progress during batch writing @@ -548,8 +544,6 @@ private void ProcessParsedLogs(List logEvents, string fileName) LoadingProgress = $"Adding to view: {progress}%"; } } - - tab.LogCount = logEvents.Count; LastLogTimestamp = DateTime.Now.ToString("HH:mm:ss"); } From 60d56a1f77dc41f0887f599a6a2aa41c11eb39f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Thu, 29 Jan 2026 15:52:24 +0100 Subject: [PATCH 07/10] refactor(wpf): remove obsolete ItemsSource from NLogViewer Remove ItemsSource dependency property and related handlers. Log events are supplied only via CacheTarget (CacheTargetProperty when set, else CacheTarget.GetInstance). Update comments to describe CacheTarget-based data source and simplify OnPauseChanged, StartListen, ClearCommand, and Loaded logic. --- ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs | 89 ++----------------- 1 file changed, 8 insertions(+), 81 deletions(-) diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index 09f2d7a..5eddc51 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -562,57 +562,6 @@ public CollectionViewSource LogEvents public static readonly DependencyProperty LogEventsProperty = DependencyProperty.Register(nameof(LogEvents), typeof(CollectionViewSource), typeof(NLogViewer), new PropertyMetadata(null)); - /// - /// External items source for log events. When set, the control will use this collection instead of the internal cache target. - /// If this is set, StartListen() will not be called automatically and the control will not subscribe to CacheTarget. - /// - [Category("NLogViewer")] - [Browsable(true)] - [Description("External collection of LogEventInfo items. When set, the control uses this instead of CacheTarget.")] - public ObservableCollection ItemsSource - { - get => (ObservableCollection)GetValue(ItemsSourceProperty); - set => SetValue(ItemsSourceProperty, value); - } - - /// - /// The DependencyProperty. - /// - public static readonly DependencyProperty ItemsSourceProperty = - DependencyProperty.Register(nameof(ItemsSource), typeof(ObservableCollection), typeof(NLogViewer), - new PropertyMetadata(null, OnItemsSourceChanged)); - - private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is NLogViewer instance) - { - instance.OnItemsSourceChanged(e.OldValue as ObservableCollection, - e.NewValue as ObservableCollection); - } - } - - private void OnItemsSourceChanged(ObservableCollection oldValue, ObservableCollection newValue) - { - // Update the CollectionViewSource to point to the new source - if (newValue != null) - { - LogEvents = new CollectionViewSource { Source = newValue }; - UpdateFilter(); - - // If using external source, stop listening to cache target - if (_isListening) - { - StopListen(); - } - } - else - { - // Fall back to internal collection - LogEvents = new CollectionViewSource { Source = _LogEventInfos }; - UpdateFilter(); - } - } - /// /// Automatically scroll to the newest entry /// @@ -917,10 +866,6 @@ private static void OnPauseChanged(DependencyObject d, DependencyPropertyChanged { if (d is NLogViewer viewer && !DesignerProperties.GetIsInDesignMode(viewer)) { - // Only handle pause/resume if not using external ItemsSource - if (viewer.ItemsSource != null) - return; - if ((bool)e.NewValue) { // Pause: Stop listening for better performance @@ -929,7 +874,7 @@ private static void OnPauseChanged(DependencyObject d, DependencyPropertyChanged else { // Resume: Start listening again - viewer.StartListen(); + viewer.StartListen(viewer.CacheTarget); } } } @@ -1751,16 +1696,11 @@ public bool ShowControlButtons /// /// Starts listening for log events by subscribing to the cache target. - /// This method should be called when the control needs to resume listening for logs, - /// such as when undocking from a docking system or when the window loads again. - /// Note: This method will not start listening if ItemsSource is set (external mode). + /// Subscribes to when set, otherwise uses and subscribes to that. + /// Call when the control needs to resume listening (e.g. when undocking or when the window loads again). /// public void StartListen(ICacheTarget? target = null) { - // Don't start listening if using external ItemsSource - if (ItemsSource != null) - return; - if (_isListening || DesignerProperties.GetIsInDesignMode(this)) return; @@ -1848,7 +1788,7 @@ public NLogViewer() if (DesignerProperties.GetIsInDesignMode(this)) return; - // Initialize with internal collection (will be overridden if ItemsSource is set) + // Initialize with internal collection (filled from CacheTarget subscription; target is CacheTargetProperty when set, else CacheTarget.GetInstance). LogEvents = new CollectionViewSource {Source = _LogEventInfos}; ActiveSearchTerms = new ObservableCollection(); UpdateFilter(); // Initialize filter @@ -1856,18 +1796,8 @@ public NLogViewer() Loaded += _OnLoaded; Unloaded += _OnUnloaded; - // ClearCommand: Only clear internal collection if not using external ItemsSource - ClearCommand = new RelayCommand(() => - { - if (ItemsSource == null) - { - _LogEventInfos.Clear(); - } - else - { - ItemsSource.Clear(); - } - }); + // ClearCommand: clears the internal collection (data comes from CacheTarget or GetInstance). + ClearCommand = new RelayCommand(() => _LogEventInfos.Clear()); AddSearchTermCommand = new RelayCommand(AddSearchTerm); ClearAllSearchTermsCommand = new RelayCommand(ClearAllSearchTerms); RemoveSearchTermCommand = new RelayCommand(RemoveSearchTerm); @@ -1938,11 +1868,8 @@ private void _OnLoaded(object sender, RoutedEventArgs e) UpdateColumnVisibility(); } - // Start listening for log events only if not using external ItemsSource - if (ItemsSource == null) - { - StartListen(); - } + // Start listening: subscribe to CacheTarget if set, else to CacheTarget.GetInstance(). + StartListen(); } #endregion From e7983415ff6c2505c260ca57a1c59473286c65ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Thu, 29 Jan 2026 16:37:03 +0100 Subject: [PATCH 08/10] refactor(app): replay log cache only to first subscriber Replace ReplaySubject with Defer+Concat+Publish+RefCount so the first subscriber receives buffered events and later subscribers receive only events from subscription time. AddLogEvent fills a buffer until first connect, then routes to a live Subject. Thread-safe via lock. --- .../Models/LogTabViewModel.cs | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs index 5bfea1e..395a02a 100644 --- a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs +++ b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; using NLog; @@ -76,12 +77,29 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - private readonly ReplaySubject _cacheSubject = new(10000); + private readonly object _gate = new(); + private readonly List _buffer = new(); + private Subject? _liveSubject; + private bool _hasConnected; /// - /// Observable stream of log events. Replays buffered events to new subscribers so nothing is lost before subscription. + /// Observable stream of log events. Replays buffered events only to the first subscriber; later subscribers receive only new events from subscription time. /// - public IObservable Cache => _cacheSubject.AsObservable(); + public IObservable Cache => Observable.Defer(() => + { + lock (_gate) + { + if (!_hasConnected) + { + _hasConnected = true; + _liveSubject = new Subject(); + var snapshot = _buffer.ToList(); + _buffer.Clear(); + return snapshot.ToObservable().Concat(_liveSubject); + } + return _liveSubject!; + } + }).Publish().RefCount(); /// /// Pushes a log event into the cache. Subscribers (e.g. NLogViewer) receive it immediately or via replay when they subscribe later. @@ -89,7 +107,21 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName /// The log event to add. public void AddLogEvent(LogEventInfo logEvent) { - _cacheSubject.OnNext(logEvent); + lock (_gate) + { + if (_liveSubject != null) + { + _liveSubject.OnNext(logEvent); + } + else + { + _buffer.Add(logEvent); + while (_buffer.Count > MaxCount) + { + _buffer.RemoveAt(0); + } + } + } LogCount++; } } From 7606dbaab4465b37820a70d5f5c532573440a64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Mon, 2 Feb 2026 09:12:10 +0100 Subject: [PATCH 09/10] fix(wpf): use fallback dispatcher when no parent window for unit-test compatibility NLogViewer no longer requires a parent Window; uses Application.Current.Dispatcher or Dispatcher.CurrentDispatcher when Window.GetWindow returns null. Add TestCacheTarget (ICacheTarget test double) and WpfTestHelper.CreateViewerWithTestData/WaitForViewerEvents. Refactor NLogViewerFilterTests to use CreateViewerWithTestData so tests exercise the real cache subscription path. --- .../NLogViewerFilterTests.cs | 33 +++++---------- .../TestCacheTarget.cs | 38 +++++++++++++++++ .../WpfTestHelper.cs | 41 ++++++++++++++++++- ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs | 34 ++++++++------- 4 files changed, 105 insertions(+), 41 deletions(-) create mode 100644 tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs diff --git a/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs b/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs index 5af6c6e..9a81f4b 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs @@ -47,14 +47,13 @@ public void AddRegexSearchTerm_LoggerNameWithDot_MatchesExactLoggerName() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Test message 1"), new(LogLevel.Info, "MyAppLogger", "Test message 2"), new(LogLevel.Info, "MyApp.Other", "Test message 3") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTerm("MyApp.Logger"); @@ -97,14 +96,13 @@ public void AddRegexSearchTermExclude_ExcludesMatchingEntries() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Test message 1"), new(LogLevel.Info, "OtherLogger", "Test message 2"), new(LogLevel.Info, "MyApp.Other", "Test message 3") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTermExclude("MyApp.Logger"); @@ -127,13 +125,12 @@ public void AddRegexSearchTermExclude_ShowsNonMatchingEntries() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Test message 1"), new(LogLevel.Info, "OtherLogger", "Test message 2") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTermExclude("MyApp.Logger"); @@ -154,7 +151,6 @@ public void Filter_IncludeAndExcludeTerms_AppliesBothCorrectly() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Error occurred"), @@ -162,7 +158,7 @@ public void Filter_IncludeAndExcludeTerms_AppliesBothCorrectly() new(LogLevel.Info, "OtherLogger", "Error occurred"), new(LogLevel.Info, "OtherLogger", "Info message") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTerm("Error"); // Include: must contain "Error" @@ -185,7 +181,6 @@ public void Filter_MultipleIncludeTerms_RequiresAllToMatch() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Error occurred"), @@ -193,7 +188,7 @@ public void Filter_MultipleIncludeTerms_RequiresAllToMatch() new(LogLevel.Info, "OtherLogger", "Error occurred"), new(LogLevel.Info, "OtherLogger", "Warning message") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTerm("Error"); @@ -216,14 +211,13 @@ public void Filter_MultipleExcludeTerms_HidesIfAnyMatches() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Test message 1"), new(LogLevel.Info, "OtherLogger", "Test message 2"), new(LogLevel.Info, "ThirdLogger", "Test message 3") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTermExclude("MyApp.Logger"); @@ -245,13 +239,12 @@ public void Filter_EmptySearchTerms_ShowsAllEntries() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Test message 1"), new(LogLevel.Info, "OtherLogger", "Test message 2") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act - no search terms added @@ -318,12 +311,11 @@ public void Filter_ExcludePatternMatches_EntryIsHidden() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Test message") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTermExclude("MyApp.Logger"); @@ -343,12 +335,11 @@ public void Filter_ExcludePatternDoesNotMatch_EntryIsShown() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "OtherLogger", "Test message") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTermExclude("MyApp.Logger"); @@ -369,13 +360,12 @@ public void Filter_MessageField_IsAlsoFiltered() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Error occurred"), new(LogLevel.Info, "OtherLogger", "Info message") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTerm("Error"); @@ -396,7 +386,6 @@ public void Filter_ExcludeMessageField_WorksCorrectly() WpfTestHelper.RunOnStaThread(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "OtherLogger1", "Error occurred"), @@ -404,7 +393,7 @@ public void Filter_ExcludeMessageField_WorksCorrectly() new(LogLevel.Info, "OtherLogger3", "error"), new(LogLevel.Info, "OtherLogger4", "Error") }; - viewer.ItemsSource = testData; + var viewer = WpfTestHelper.CreateViewerWithTestData(testData); // Act viewer.AddRegexSearchTermExclude("Error"); diff --git a/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs b/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs new file mode 100644 index 0000000..ffa7c59 --- /dev/null +++ b/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using NLog; +using Sentinel.NLogViewer.Wpf.Targets; + +namespace Sentinel.NLogViewer.App.Tests; + +/// +/// Test double for ICacheTarget. Exposes an observable cache and allows pre-filling events +/// so that subscribers (e.g. NLogViewer) receive them when they subscribe (ReplaySubject). +/// +public class TestCacheTarget : ICacheTarget +{ + private readonly ReplaySubject _subject = new ReplaySubject(bufferSize: 1000); + + /// + public IObservable Cache => _subject.AsObservable(); + + /// + /// Pushes a single log event into the cache. Subscribers receive it; late subscribers get it replayed. + /// + public void Add(LogEventInfo logEvent) + { + _subject.OnNext(logEvent); + } + + /// + /// Pushes multiple log events into the cache. Subscribers receive them; late subscribers get them replayed. + /// + public void AddRange(IEnumerable logEvents) + { + if (logEvents == null) return; + foreach (var logEvent in logEvents) + _subject.OnNext(logEvent); + } +} diff --git a/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs b/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs index c59c556..43bebbd 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; -using System.Threading.Tasks; using System.Windows; +using System.Windows.Threading; +using NLog; +using Sentinel.NLogViewer.Wpf; namespace Sentinel.NLogViewer.App.Tests { @@ -10,6 +14,41 @@ namespace Sentinel.NLogViewer.App.Tests /// public static class WpfTestHelper { + /// + /// Creates an NLogViewer, feeds it with the given log events via a TestCacheTarget, + /// and waits for the viewer to process them (async pipeline + dispatcher). Call from within RunOnStaThread. + /// + /// Log events to feed into the viewer. + /// The viewer with CacheTarget set and events processed. + public static Wpf.NLogViewer CreateViewerWithTestData(IEnumerable testData) + { + var list = testData?.ToList() ?? new List(); + var viewer = new Wpf.NLogViewer(); + var cache = new TestCacheTarget(); + cache.AddRange(list); + viewer.CacheTarget = cache; + WaitForViewerEvents(viewer, list.Count); + return viewer; + } + + /// + /// Waits until the viewer's LogEvents.View contains at least expectedCount items (or timeout). + /// Pumps the current dispatcher so that async subscription callbacks run. + /// NLogViewer uses SubscribeOn(Scheduler.Default) and Buffer(100ms), so an initial delay is needed. + /// + public static void WaitForViewerEvents(Wpf.NLogViewer viewer, int expectedCount, int timeoutMs = 2000) + { + // Give the async pipeline time: subscription on thread pool + Buffer(100ms) + dispatch + Thread.Sleep(200); + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + Dispatcher.CurrentDispatcher.Invoke(DispatcherPriority.Background, () => { }); + var count = viewer.LogEvents?.View?.Cast().Count() ?? 0; + if (count >= expectedCount) return; + Thread.Sleep(50); + } + } /// /// Runs an action on an STA thread /// diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index 5eddc51..c0c1058 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -1668,7 +1668,7 @@ public bool ShowControlButtons private ObservableCollection _LogEventInfos { get; } = new ObservableCollection(); private IDisposable _Subscription; - private Window _ParentWindow; + private Window? _ParentWindow; private bool _isListening = false; // Store original column references and widths for restoration @@ -1704,26 +1704,24 @@ public void StartListen(ICacheTarget? target = null) if (_isListening || DesignerProperties.GetIsInDesignMode(this)) return; - // Ensure we have a parent window reference - // add hook to parent window to dispose subscription - // use case: - // NLogViewer is used in a new window inside of a TabControl. If you switch the TabItems, - // the unloaded event is called and would dispose the subscription, even if the control is still alive. - if (_ParentWindow == null && Window.GetWindow(this) is { } window) - { - _ParentWindow = window; - _ParentWindow.Closed += _ParentWindowOnClosed; - } + // Resolve Dispatcher: prefer parent window (for cleanup on Closed/Unloaded), else Application/Current (e.g. unit tests without Window). + var window = Window.GetWindow(this); + if (_ParentWindow == null && window != null) + { + _ParentWindow = window; + _ParentWindow.Closed += _ParentWindowOnClosed; + } - if (_ParentWindow == null) - return; - - target ??= Targets.CacheTarget.GetInstance(targetName: TargetName); - - _Subscription = target.Cache.SubscribeOn(Scheduler.Default) + var dispatcher = window?.Dispatcher ?? Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + if (dispatcher == null) + return; + + target ??= Targets.CacheTarget.GetInstance(targetName: TargetName); + + _Subscription = target.Cache.SubscribeOn(Scheduler.Default) .Buffer(TimeSpan.FromMilliseconds(100)) .Where(x => x.Any()) - .ObserveOn(new DispatcherSynchronizationContext(_ParentWindow.Dispatcher)) + .ObserveOn(new DispatcherSynchronizationContext(dispatcher)) .Subscribe(infos => { using (LogEvents.DeferRefresh()) From df627e136b3a788fff70e89efc46eba86eef604d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=2EB=C3=B6xler?= Date: Mon, 2 Feb 2026 11:05:59 +0100 Subject: [PATCH 10/10] fix(tests): run NLogViewerFilterTests on shared STA thread to fix batch failures NLogViewerFilterTests passed individually but failed when run together because later tests used Application.Current.Dispatcher from the first test's (dead) STA thread. Use a single STA thread and WPF Application for all filter tests via WpfStaContextFixture and IClassFixture. Simplify WpfTestHelper (remove RunOnStaThread; use StartListen in CreateViewerWithTestData). Remove unused usings in test files. --- .../NLogViewerFilterTests.cs | 38 ++-- .../Parsers/JsonLogParserTests.cs | 2 - .../Parsers/PlainTextParserTests.cs | 2 - .../TestCacheTarget.cs | 2 - .../WpfStaContextFixture.cs | 82 ++++++++ .../WpfTestHelper.cs | 179 ++++-------------- 6 files changed, 144 insertions(+), 161 deletions(-) create mode 100644 tests/Sentinel.NLogViewer.App.Tests/WpfStaContextFixture.cs diff --git a/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs b/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs index 9a81f4b..3de6901 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs @@ -11,17 +11,19 @@ namespace Sentinel.NLogViewer.App.Tests /// /// Unit tests for NLogViewer filter commands (AddRegexSearchTerm and AddRegexSearchTermExclude) /// - public class NLogViewerFilterTests : IDisposable + public class NLogViewerFilterTests : IClassFixture, IDisposable { - public NLogViewerFilterTests() + private readonly WpfStaContextFixture _context; + + public NLogViewerFilterTests(WpfStaContextFixture fixture) { - // No viewer initialization here - each test creates its own + _context = fixture ?? throw new ArgumentNullException(nameof(fixture)); } [Fact] public void AddRegexSearchTerm_EscapesSpecialCharacters_CreatesLiteralPattern() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -44,7 +46,7 @@ public void AddRegexSearchTerm_EscapesSpecialCharacters_CreatesLiteralPattern() [Fact] public void AddRegexSearchTerm_LoggerNameWithDot_MatchesExactLoggerName() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -71,7 +73,7 @@ public void AddRegexSearchTerm_LoggerNameWithDot_MatchesExactLoggerName() [Fact] public void AddRegexSearchTermExclude_CreatesNegativeLookaheadPattern() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -93,7 +95,7 @@ public void AddRegexSearchTermExclude_CreatesNegativeLookaheadPattern() [Fact] public void AddRegexSearchTermExclude_ExcludesMatchingEntries() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -122,7 +124,7 @@ public void AddRegexSearchTermExclude_ExcludesMatchingEntries() [Fact] public void AddRegexSearchTermExclude_ShowsNonMatchingEntries() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -148,7 +150,7 @@ public void AddRegexSearchTermExclude_ShowsNonMatchingEntries() [Fact] public void Filter_IncludeAndExcludeTerms_AppliesBothCorrectly() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -178,7 +180,7 @@ public void Filter_IncludeAndExcludeTerms_AppliesBothCorrectly() [Fact] public void Filter_MultipleIncludeTerms_RequiresAllToMatch() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -208,7 +210,7 @@ public void Filter_MultipleIncludeTerms_RequiresAllToMatch() [Fact] public void Filter_MultipleExcludeTerms_HidesIfAnyMatches() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -236,7 +238,7 @@ public void Filter_MultipleExcludeTerms_HidesIfAnyMatches() [Fact] public void Filter_EmptySearchTerms_ShowsAllEntries() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -260,7 +262,7 @@ public void Filter_EmptySearchTerms_ShowsAllEntries() [Fact] public void AddRegexSearchTerm_SpecialRegexCharacters_AreEscaped() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -285,7 +287,7 @@ public void AddRegexSearchTerm_SpecialRegexCharacters_AreEscaped() [Fact] public void AddRegexSearchTermExclude_SpecialRegexCharacters_AreEscapedInNegativeLookahead() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -308,7 +310,7 @@ public void AddRegexSearchTermExclude_SpecialRegexCharacters_AreEscapedInNegativ [Fact] public void Filter_ExcludePatternMatches_EntryIsHidden() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -332,7 +334,7 @@ public void Filter_ExcludePatternMatches_EntryIsHidden() [Fact] public void Filter_ExcludePatternDoesNotMatch_EntryIsShown() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -357,7 +359,7 @@ public void Filter_ExcludePatternDoesNotMatch_EntryIsShown() [Fact] public void Filter_MessageField_IsAlsoFiltered() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection @@ -383,7 +385,7 @@ public void Filter_MessageField_IsAlsoFiltered() [Fact] public void Filter_ExcludeMessageField_WorksCorrectly() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var testData = new ObservableCollection diff --git a/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs index b45de1c..dd7b1fa 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using NLog; using Sentinel.NLogViewer.App.Parsers; using Xunit; diff --git a/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs index 5150cc1..6d0b5c0 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using NLog; using Sentinel.NLogViewer.App.Parsers; using Xunit; diff --git a/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs b/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs index ffa7c59..35d08b1 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Reactive.Linq; using System.Reactive.Subjects; using NLog; diff --git a/tests/Sentinel.NLogViewer.App.Tests/WpfStaContextFixture.cs b/tests/Sentinel.NLogViewer.App.Tests/WpfStaContextFixture.cs new file mode 100644 index 0000000..060a6ef --- /dev/null +++ b/tests/Sentinel.NLogViewer.App.Tests/WpfStaContextFixture.cs @@ -0,0 +1,82 @@ +using System.Windows; +using System.Windows.Threading; + +namespace Sentinel.NLogViewer.App.Tests +{ + /// + /// xUnit class fixture that provides a single STA thread with a WPF Application + /// so that all tests run in the same STA/Application context and share one Dispatcher. + /// Use for WPF tests that would otherwise see a stale Application.Current when run together. + /// + public class WpfStaContextFixture : IDisposable + { + private Dispatcher _dispatcher; + private readonly Thread _thread; + private readonly ManualResetEvent _ready = new ManualResetEvent(false); + private const int StaThreadReadyTimeoutMs = 5000; + + public WpfStaContextFixture() + { + _thread = new Thread(StaThreadProc) + { + IsBackground = true + }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + if (!_ready.WaitOne(StaThreadReadyTimeoutMs)) + throw new InvalidOperationException("STA thread did not signal ready within timeout."); + if (_dispatcher == null) + throw new InvalidOperationException("STA thread did not set Dispatcher."); + } + + private void StaThreadProc() + { + var app = new Application(); + app.ShutdownMode = ShutdownMode.OnExplicitShutdown; + _dispatcher = Dispatcher.CurrentDispatcher; + _ready.Set(); + Dispatcher.Run(); + } + + /// + /// Runs the given action on the shared STA thread (same thread as Application.Current). + /// Blocks until the action completes. + /// + public void RunOnSta(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + _dispatcher.Invoke(action); + } + + /// + /// Runs the given function on the shared STA thread and returns its result. + /// Blocks until the function completes. + /// + public T RunOnSta(Func func) + { + if (func == null) + throw new ArgumentNullException(nameof(func)); + return _dispatcher.Invoke(func); + } + + /// + /// Shuts down the WPF Application on the STA thread and joins the thread. + /// + public void Dispose() + { + if (_dispatcher != null && _dispatcher.Thread.IsAlive) + { + try + { + _dispatcher.Invoke(() => Application.Current?.Shutdown()); + } + catch (Exception) + { + // Best effort; thread may already be shutting down + } + } + _thread.Join(TimeSpan.FromSeconds(5)); + } + } +} diff --git a/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs b/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs index 43bebbd..c4444f4 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs @@ -1,141 +1,46 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Windows; using System.Windows.Threading; using NLog; -using Sentinel.NLogViewer.Wpf; -namespace Sentinel.NLogViewer.App.Tests -{ - /// - /// Helper class to run WPF tests on STA thread - /// - public static class WpfTestHelper - { - /// - /// Creates an NLogViewer, feeds it with the given log events via a TestCacheTarget, - /// and waits for the viewer to process them (async pipeline + dispatcher). Call from within RunOnStaThread. - /// - /// Log events to feed into the viewer. - /// The viewer with CacheTarget set and events processed. - public static Wpf.NLogViewer CreateViewerWithTestData(IEnumerable testData) - { - var list = testData?.ToList() ?? new List(); - var viewer = new Wpf.NLogViewer(); - var cache = new TestCacheTarget(); - cache.AddRange(list); - viewer.CacheTarget = cache; - WaitForViewerEvents(viewer, list.Count); - return viewer; - } - - /// - /// Waits until the viewer's LogEvents.View contains at least expectedCount items (or timeout). - /// Pumps the current dispatcher so that async subscription callbacks run. - /// NLogViewer uses SubscribeOn(Scheduler.Default) and Buffer(100ms), so an initial delay is needed. - /// - public static void WaitForViewerEvents(Wpf.NLogViewer viewer, int expectedCount, int timeoutMs = 2000) - { - // Give the async pipeline time: subscription on thread pool + Buffer(100ms) + dispatch - Thread.Sleep(200); - var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); - while (DateTime.UtcNow < deadline) - { - Dispatcher.CurrentDispatcher.Invoke(DispatcherPriority.Background, () => { }); - var count = viewer.LogEvents?.View?.Cast().Count() ?? 0; - if (count >= expectedCount) return; - Thread.Sleep(50); - } - } - /// - /// Runs an action on an STA thread - /// - public static void RunOnStaThread(Action action) - { - if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) - { - // Already on STA thread, just execute - action(); - return; - } - - // Create STA thread and run action - Exception exception = null; - var thread = new Thread(() => - { - try - { - // Initialize WPF application context if not already done - if (Application.Current == null) - { - var app = new Application(); - app.ShutdownMode = ShutdownMode.OnExplicitShutdown; - } - - action(); - } - catch (Exception ex) - { - exception = ex; - } - }); - - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (exception != null) - { - throw exception; - } - } - - /// - /// Runs a function on an STA thread and returns the result - /// - public static T RunOnStaThread(Func func) - { - if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) - { - // Already on STA thread, just execute - return func(); - } - - // Create STA thread and run function - T result = default(T); - Exception exception = null; - var thread = new Thread(() => - { - try - { - // Initialize WPF application context if not already done - if (Application.Current == null) - { - var app = new Application(); - app.ShutdownMode = ShutdownMode.OnExplicitShutdown; - } - - result = func(); - } - catch (Exception ex) - { - exception = ex; - } - }); - - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (exception != null) - { - throw exception; - } - - return result; - } - } -} +namespace Sentinel.NLogViewer.App.Tests; +/// +/// Helper class to run WPF tests on STA thread +/// +public static class WpfTestHelper +{ + /// + /// Creates an NLogViewer, feeds it with the given log events via a TestCacheTarget, + /// and waits for the viewer to process them (async pipeline + dispatcher). Call from within RunOnStaThread. + /// + /// Log events to feed into the viewer. + /// The viewer with CacheTarget set and events processed. + public static Wpf.NLogViewer CreateViewerWithTestData(IEnumerable testData) + { + var list = testData?.ToList() ?? new List(); + var viewer = new Wpf.NLogViewer(); + var cache = new TestCacheTarget(); + cache.AddRange(list); + viewer.StartListen(cache); + WaitForViewerEvents(viewer, list.Count); + return viewer; + } + + /// + /// Waits until the viewer's LogEvents.View contains at least expectedCount items (or timeout). + /// Pumps the current dispatcher so that async subscription callbacks run. + /// NLogViewer uses SubscribeOn(Scheduler.Default) and Buffer(100ms), so an initial delay is needed. + /// + public static void WaitForViewerEvents(Wpf.NLogViewer viewer, int expectedCount, int timeoutMs = 2000) + { + // Give the async pipeline time: subscription on thread pool + Buffer(100ms) + dispatch + Thread.Sleep(200); + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + Dispatcher.CurrentDispatcher.Invoke(DispatcherPriority.Background, () => { }); + var count = viewer.LogEvents?.View?.Cast().Count() ?? 0; + if (count >= expectedCount) return; + Thread.Sleep(50); + } + } +} \ No newline at end of file