diff --git a/app/Sentinel.NLogViewer.App/MainWindow.xaml b/app/Sentinel.NLogViewer.App/MainWindow.xaml index 9296edf..8e2ab57 100644 --- a/app/Sentinel.NLogViewer.App/MainWindow.xaml +++ b/app/Sentinel.NLogViewer.App/MainWindow.xaml @@ -18,7 +18,11 @@ - + @@ -43,6 +47,12 @@ Command="{Binding OpenSettingsCommand}" InputGestureText="Ctrl+,"/> + + + + + diff --git a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs index 48dc8fc..395a02a 100644 --- a/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs +++ b/app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs @@ -1,14 +1,17 @@ -using System.Collections.ObjectModel; +using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; using NLog; +using Sentinel.NLogViewer.Wpf.Targets; +using System.Reactive.Linq; +using System.Reactive.Subjects; 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; @@ -67,14 +70,60 @@ public int MaxCount } } - public ObservableCollection LogEventInfos { get; } = new(); - public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + + private readonly object _gate = new(); + private readonly List _buffer = new(); + private Subject? _liveSubject; + private bool _hasConnected; + + /// + /// 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 => 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. + /// + /// The log event to add. + public void AddLogEvent(LogEventInfo logEvent) + { + lock (_gate) + { + if (_liveSubject != null) + { + _liveSubject.OnNext(logEvent); + } + else + { + _buffer.Add(logEvent); + while (_buffer.Count > MaxCount) + { + _buffer.RemoveAt(0); + } + } + } + LogCount++; + } } } 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/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/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 678827f..65d5ea9 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(); @@ -114,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}"; } @@ -137,6 +138,8 @@ public LogTabViewModel? SelectedTab { _selectedTab = value; OnPropertyChanged(); + // Update ExportLogsCommand CanExecute + ((RelayCommand)ExportLogsCommand).RaiseCanExecuteChanged(); } } } @@ -202,6 +205,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 @@ -522,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 @@ -541,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"); } @@ -584,6 +585,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/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs b/tests/Sentinel.NLogViewer.App.Tests/NLogViewerFilterTests.cs index 5af6c6e..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,17 +46,16 @@ public void AddRegexSearchTerm_EscapesSpecialCharacters_CreatesLiteralPattern() [Fact] public void AddRegexSearchTerm_LoggerNameWithDot_MatchesExactLoggerName() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -72,7 +73,7 @@ public void AddRegexSearchTerm_LoggerNameWithDot_MatchesExactLoggerName() [Fact] public void AddRegexSearchTermExclude_CreatesNegativeLookaheadPattern() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -94,17 +95,16 @@ public void AddRegexSearchTermExclude_CreatesNegativeLookaheadPattern() [Fact] public void AddRegexSearchTermExclude_ExcludesMatchingEntries() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -124,16 +124,15 @@ public void AddRegexSearchTermExclude_ExcludesMatchingEntries() [Fact] public void AddRegexSearchTermExclude_ShowsNonMatchingEntries() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -151,10 +150,9 @@ public void AddRegexSearchTermExclude_ShowsNonMatchingEntries() [Fact] public void Filter_IncludeAndExcludeTerms_AppliesBothCorrectly() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Error occurred"), @@ -162,7 +160,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" @@ -182,10 +180,9 @@ public void Filter_IncludeAndExcludeTerms_AppliesBothCorrectly() [Fact] public void Filter_MultipleIncludeTerms_RequiresAllToMatch() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "MyApp.Logger", "Error occurred"), @@ -193,7 +190,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"); @@ -213,17 +210,16 @@ public void Filter_MultipleIncludeTerms_RequiresAllToMatch() [Fact] public void Filter_MultipleExcludeTerms_HidesIfAnyMatches() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -242,16 +238,15 @@ public void Filter_MultipleExcludeTerms_HidesIfAnyMatches() [Fact] public void Filter_EmptySearchTerms_ShowsAllEntries() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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 @@ -267,7 +262,7 @@ public void Filter_EmptySearchTerms_ShowsAllEntries() [Fact] public void AddRegexSearchTerm_SpecialRegexCharacters_AreEscaped() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -292,7 +287,7 @@ public void AddRegexSearchTerm_SpecialRegexCharacters_AreEscaped() [Fact] public void AddRegexSearchTermExclude_SpecialRegexCharacters_AreEscapedInNegativeLookahead() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange var viewer = new WpfNLogViewer(); @@ -315,15 +310,14 @@ public void AddRegexSearchTermExclude_SpecialRegexCharacters_AreEscapedInNegativ [Fact] public void Filter_ExcludePatternMatches_EntryIsHidden() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -340,15 +334,14 @@ public void Filter_ExcludePatternMatches_EntryIsHidden() [Fact] public void Filter_ExcludePatternDoesNotMatch_EntryIsShown() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -366,16 +359,15 @@ public void Filter_ExcludePatternDoesNotMatch_EntryIsShown() [Fact] public void Filter_MessageField_IsAlsoFiltered() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // 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"); @@ -393,10 +385,9 @@ public void Filter_MessageField_IsAlsoFiltered() [Fact] public void Filter_ExcludeMessageField_WorksCorrectly() { - WpfTestHelper.RunOnStaThread(() => + _context.RunOnSta(() => { // Arrange - var viewer = new WpfNLogViewer(); var testData = new ObservableCollection { new(LogLevel.Info, "OtherLogger1", "Error occurred"), @@ -404,7 +395,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/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 5a98887..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; @@ -33,9 +31,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 +61,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 +119,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 +147,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 +173,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 +199,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 +225,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 +255,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 +283,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 +322,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() diff --git a/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs b/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs new file mode 100644 index 0000000..35d08b1 --- /dev/null +++ b/tests/Sentinel.NLogViewer.App.Tests/TestCacheTarget.cs @@ -0,0 +1,36 @@ +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/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 c59c556..c4444f4 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/WpfTestHelper.cs @@ -1,102 +1,46 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; +using System.Windows.Threading; +using NLog; -namespace Sentinel.NLogViewer.App.Tests -{ - /// - /// Helper class to run WPF tests on STA thread - /// - public static class WpfTestHelper - { - /// - /// 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 diff --git a/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs b/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs new file mode 100644 index 0000000..936c464 --- /dev/null +++ b/ui/Sentinel.NLogViewer.Wpf/Models/ExportParameter.cs @@ -0,0 +1,36 @@ +using Sentinel.NLogViewer.Wpf.Resolver; + +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; + + /// + /// 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 8e86002..c0c1058 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; @@ -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 { @@ -515,73 +518,49 @@ public string TargetName /// The DependencyProperty. /// public static readonly DependencyProperty TargetNameProperty = DependencyProperty.Register(nameof(TargetName), typeof(string), typeof(NLogViewer), new PropertyMetadata(null)); - - /// - /// Private DP to bind to the gui - /// - [Category("NLogViewer")] - public CollectionViewSource LogEvents - { - get => (CollectionViewSource) GetValue(LogEventsProperty); - private set => SetValue(LogEventsProperty, value); - } /// - /// The DependencyProperty. - /// - 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. + /// 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("External collection of LogEventInfo items. When set, the control uses this instead of CacheTarget.")] - public ObservableCollection ItemsSource + [Description("Cache target for log events. When set, the control subscribes via StartListen.")] + public ICacheTarget? CacheTarget { - get => (ObservableCollection)GetValue(ItemsSourceProperty); - set => SetValue(ItemsSourceProperty, value); + get => (ICacheTarget?)GetValue(CacheTargetProperty); + set => SetValue(CacheTargetProperty, value); } /// - /// The DependencyProperty. + /// The DependencyProperty. /// - public static readonly DependencyProperty ItemsSourceProperty = - DependencyProperty.Register(nameof(ItemsSource), typeof(ObservableCollection), typeof(NLogViewer), - new PropertyMetadata(null, OnItemsSourceChanged)); + public static readonly DependencyProperty CacheTargetProperty = DependencyProperty.Register( + nameof(CacheTarget), + typeof(ICacheTarget), + typeof(NLogViewer), + new PropertyMetadata(null, OnCacheTargetChanged)); - private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private static void OnCacheTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - if (d is NLogViewer instance) - { - instance.OnItemsSourceChanged(e.OldValue as ObservableCollection, - e.NewValue as ObservableCollection); - } + if (d is NLogViewer instance && e.NewValue is ICacheTarget target && target != null) + instance.StartListen(target); } - private void OnItemsSourceChanged(ObservableCollection oldValue, ObservableCollection newValue) + /// + /// Private DP to bind to the gui + /// + [Category("NLogViewer")] + public CollectionViewSource LogEvents { - // 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(); - } + get => (CollectionViewSource) GetValue(LogEventsProperty); + private set => SetValue(LogEventsProperty, value); } + + /// + /// The DependencyProperty. + /// + public static readonly DependencyProperty LogEventsProperty = DependencyProperty.Register(nameof(LogEvents), + typeof(CollectionViewSource), typeof(NLogViewer), new PropertyMetadata(null)); /// /// Automatically scroll to the newest entry @@ -825,6 +804,46 @@ 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)); + + /// + /// 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 @@ -847,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 @@ -859,7 +874,7 @@ private static void OnPauseChanged(DependencyObject d, DependencyPropertyChanged else { // Resume: Start listening again - viewer.StartListen(); + viewer.StartListen(viewer.CacheTarget); } } } @@ -949,6 +964,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 @@ -1464,6 +1494,85 @@ 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, parameter.CustomFormatter); + 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 + /// 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) + { + var formattedLine = formatter.Format( + logEvent, + TimeStampResolver ?? new TimeStampResolver(), + LoggerNameResolver ?? new LoggerNameResolver(), + MessageResolver ?? new MessageResolver()); + + writer.WriteLine(formattedLine); + } + } + #endregion // ########################################################################################## @@ -1559,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 @@ -1587,39 +1696,32 @@ 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() + public void StartListen(ICacheTarget? target = null) { - // Don't start listening if using external ItemsSource - if (ItemsSource != null) - return; - 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; + var dispatcher = window?.Dispatcher ?? Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + if (dispatcher == null) + return; - var target = CacheTarget.GetInstance(targetName: TargetName); - - _Subscription = target.Cache.SubscribeOn(Scheduler.Default) + 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()) @@ -1684,7 +1786,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 @@ -1692,18 +1794,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); @@ -1711,8 +1803,11 @@ public NLogViewer() AddRegexSearchTermExcludeCommand = new RelayCommand(AddRegexSearchTermExclude); EditSearchTermCommand = new RelayCommand(EditSearchTerm); CopyToClipboardCommand = new RelayCommand(CopyToClipboard); - - // Filter commands are no longer needed - ToggleButtons handle the binding directly + ExportCommand = new RelayCommand(ExportLogs); + ScrollToEndCommand = new RelayCommand(() => + { + PART_ListView?.ScrollToEnd(); + }); } private void _OnUnloaded(object sender, RoutedEventArgs e) @@ -1771,11 +1866,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 diff --git a/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs b/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs new file mode 100644 index 0000000..dcc7371 --- /dev/null +++ b/ui/Sentinel.NLogViewer.Wpf/Resolver/DefaultLogExportFormatter.cs @@ -0,0 +1,36 @@ +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..9dc5109 --- /dev/null +++ b/ui/Sentinel.NLogViewer.Wpf/Resolver/ILogExportFormatter.cs @@ -0,0 +1,26 @@ +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); + } +} + + 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