Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/Sentinel.NLogViewer.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
<Grid.Resources>
<DataTemplate x:Key="TabControlContentTemplate" DataType="models:LogTabViewModel">
<!-- ReSharper disable once Xaml.BindingWithContextNotResolved -->
<dj:NLogViewer ItemsSource="{Binding DataContext.LogEventInfos, Mode=OneWay, RelativeSource={RelativeSource AncestorType=ContentPresenter}}" />
<dj:NLogViewer
x:Name="NLogViewer1"
Tag="{Binding ElementName=NLogViewer1}"
CacheTarget="{Binding DataContext, RelativeSource={RelativeSource AncestorType=ContentPresenter}}"
DataContext="{Binding DataContext.NLogViewer, Mode=OneWayToSource, RelativeSource={RelativeSource AncestorType=ContentPresenter}}"/>
</DataTemplate>
</Grid.Resources>
<Grid.RowDefinitions>
Expand All @@ -43,6 +47,12 @@
Command="{Binding OpenSettingsCommand}"
InputGestureText="Ctrl+,"/>
</MenuItem>
<MenuItem Header="{helpers:Resource Key=Menu_Logs, DefaultValue=_Logs}">
<MenuItem Header="{helpers:Resource Key=Menu_Export, DefaultValue=_Export}">
<MenuItem Header="{helpers:Resource Key=Menu_ExportLogFormat, DefaultValue=*.log format}"
Command="{Binding ExportLogsCommand}"/>
</MenuItem>
</MenuItem>
<MenuItem Header="{helpers:Resource Key=Menu_Help, DefaultValue=_Help}">
<MenuItem Header="{helpers:Resource Key=Menu_About, DefaultValue=_About...}"
Command="{Binding AboutCommand}"/>
Expand Down
57 changes: 53 additions & 4 deletions app/Sentinel.NLogViewer.App/Models/LogTabViewModel.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// ViewModel for a log tab in the TabControl
/// </summary>
public class LogTabViewModel : INotifyPropertyChanged
public class LogTabViewModel : INotifyPropertyChanged, ICacheTarget
{
private string _header = string.Empty;
private string _targetName = string.Empty;
Expand Down Expand Up @@ -67,14 +70,60 @@ public int MaxCount
}
}

public ObservableCollection<LogEventInfo> 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<LogEventInfo> _buffer = new();
private Subject<LogEventInfo>? _liveSubject;
private bool _hasConnected;

/// <summary>
/// Observable stream of log events. Replays buffered events only to the first subscriber; later subscribers receive only new events from subscription time.
/// </summary>
public IObservable<LogEventInfo> Cache => Observable.Defer(() =>
{
lock (_gate)
{
if (!_hasConnected)
{
_hasConnected = true;
_liveSubject = new Subject<LogEventInfo>();
var snapshot = _buffer.ToList();
_buffer.Clear();
return snapshot.ToObservable().Concat(_liveSubject);
}
return _liveSubject!;
}
}).Publish().RefCount();

/// <summary>
/// Pushes a log event into the cache. Subscribers (e.g. NLogViewer) receive it immediately or via replay when they subscribe later.
/// </summary>
/// <param name="logEvent">The log event to add.</param>
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++;
}
}
}

180 changes: 168 additions & 12 deletions app/Sentinel.NLogViewer.App/Parsers/PlainTextParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,45 +46,138 @@ public List<LogEventInfo> Parse(string[] lines, TextFileFormat? format)
public List<LogEventInfo> Parse(string[] lines)
{
var results = new List<LogEventInfo>();
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;
}

/// <summary>
/// Determines if a line starts a new log entry by checking for timestamp pattern at the beginning
/// </summary>
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;
}

/// <summary>
/// Parses lines using the format configuration
/// </summary>
private List<LogEventInfo> ParseWithFormat(string[] lines, TextFileFormat format)
{
var results = new List<LogEventInfo>();
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;
}

/// <summary>
/// Determines if a line starts a new log entry when using format-based parsing
/// </summary>
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);
}

/// <summary>
/// Parses a single line using the format configuration
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -200,6 +299,50 @@ private string[] SplitLine(string line, string separator)
}
}

/// <summary>
/// Parses a pipe-separated log line: timestamp | level | logger | message
/// </summary>
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;
}
}

/// <summary>
/// Attempts to parse a timestamp string using various common formats
/// </summary>
Expand All @@ -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
Expand All @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions app/Sentinel.NLogViewer.App/Resources/Resources.de.resx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@
<data name="Menu_About" xml:space="preserve">
<value>_Über...</value>
</data>
<data name="Menu_Logs" xml:space="preserve">
<value>_Protokolle</value>
</data>
<data name="Menu_Export" xml:space="preserve">
<value>_Exportieren</value>
</data>
<data name="Menu_ExportLogFormat" xml:space="preserve">
<value>*.log Format</value>
</data>
<data name="Button_StartListening" xml:space="preserve">
<value>Lauschen starten</value>
</data>
Expand Down
9 changes: 9 additions & 0 deletions app/Sentinel.NLogViewer.App/Resources/Resources.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@
<data name="Menu_About" xml:space="preserve">
<value>_About...</value>
</data>
<data name="Menu_Logs" xml:space="preserve">
<value>_Logs</value>
</data>
<data name="Menu_Export" xml:space="preserve">
<value>_Export</value>
</data>
<data name="Menu_ExportLogFormat" xml:space="preserve">
<value>*.log format</value>
</data>
<data name="Button_StartListening" xml:space="preserve">
<value>Start Listening</value>
</data>
Expand Down
Loading
Loading