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