Implementing a Log Viewer with Wpf

Implementing a log viewer with WPF

I should start selling these WPF samples instead of giving them out for free. =P

Sample Image

  • Virtualized UI (Using VirtualizingStackPanel) which provides incredibly good performance (even with 200000+ items)
  • Fully MVVM-friendly.
  • DataTemplates for each kind of LogEntry type. These give you the ability to customize as much as you want. I only implemented 2 kinds of LogEntries (basic and nested), but you get the idea. You may subclass LogEntry as much as you need. You may even support rich text or images.
  • Expandable (Nested) Items.
  • Word Wrap.
  • You can implement filtering, etc. by using a CollectionView.
  • WPF Rocks, just copy and paste my code in a File -> New -> WPF Application and see the results for yourself.
<Window x:Class="MiscSamples.LogViewer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MiscSamples"
Title="LogViewer" Height="500" Width="800">
<Window.Resources>
<Style TargetType="ItemsControl" x:Key="LogViewerStyle">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<ScrollViewer CanContentScroll="True">
<ItemsPresenter/>
</ScrollViewer>
</ControlTemplate>
</Setter.Value>
</Setter>

<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsItemsHost="True"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>

<DataTemplate DataType="{x:Type local:LogEntry}">
<Grid IsSharedSizeScope="True">
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="Index" Width="Auto"/>
<ColumnDefinition SharedSizeGroup="Date" Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>

<TextBlock Text="{Binding DateTime}" Grid.Column="0"
FontWeight="Bold" Margin="5,0,5,0"/>

<TextBlock Text="{Binding Index}" Grid.Column="1"
FontWeight="Bold" Margin="0,0,2,0" />

<TextBlock Text="{Binding Message}" Grid.Column="2"
TextWrapping="Wrap"/>
</Grid>
</DataTemplate>

<DataTemplate DataType="{x:Type local:CollapsibleLogEntry}">
<Grid IsSharedSizeScope="True">
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="Index" Width="Auto"/>
<ColumnDefinition SharedSizeGroup="Date" Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>

<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>

<TextBlock Text="{Binding DateTime}" Grid.Column="0"
FontWeight="Bold" Margin="5,0,5,0"/>

<TextBlock Text="{Binding Index}" Grid.Column="1"
FontWeight="Bold" Margin="0,0,2,0" />

<TextBlock Text="{Binding Message}" Grid.Column="2"
TextWrapping="Wrap"/>

<ToggleButton x:Name="Expander" Grid.Row="1" Grid.Column="0"
VerticalAlignment="Top" Content="+" HorizontalAlignment="Right"/>

<ItemsControl ItemsSource="{Binding Contents}" Style="{StaticResource LogViewerStyle}"
Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2"
x:Name="Contents" Visibility="Collapsed"/>

</Grid>
<DataTemplate.Triggers>
<Trigger SourceName="Expander" Property="IsChecked" Value="True">
<Setter TargetName="Contents" Property="Visibility" Value="Visible"/>
<Setter TargetName="Expander" Property="Content" Value="-"/>
</Trigger>
</DataTemplate.Triggers>
</DataTemplate>
</Window.Resources>

<DockPanel>
<TextBlock Text="{Binding Count, StringFormat='{}{0} Items'}"
DockPanel.Dock="Top"/>

<ItemsControl ItemsSource="{Binding}" Style="{StaticResource LogViewerStyle}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer CanContentScroll="True">
<ItemsPresenter/>
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsItemsHost="True"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</DockPanel>
</Window>

Code Behind:
(Notice that most of it is just boilerplate to support the example (generate random entries)

public partial class LogViewer : Window
{
private string TestData = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum";
private List<string> words;
private int maxword;
private int index;

public ObservableCollection<LogEntry> LogEntries { get; set; }

public LogViewer()
{
InitializeComponent();

random = new Random();
words = TestData.Split(' ').ToList();
maxword = words.Count - 1;

DataContext = LogEntries = new ObservableCollection<LogEntry>();
Enumerable.Range(0, 200000)
.ToList()
.ForEach(x => LogEntries.Add(GetRandomEntry()));

Timer = new Timer(x => AddRandomEntry(), null, 1000, 10);
}

private System.Threading.Timer Timer;
private System.Random random;
private void AddRandomEntry()
{
Dispatcher.BeginInvoke((Action) (() => LogEntries.Add(GetRandomEntry())));
}

private LogEntry GetRandomEntry()
{
if (random.Next(1,10) > 1)
{
return new LogEntry
{
Index = index++,
DateTime = DateTime.Now,
Message = string.Join(" ", Enumerable.Range(5, random.Next(10, 50))
.Select(x => words[random.Next(0, maxword)])),
};
}

return new CollapsibleLogEntry
{
Index = index++,
DateTime = DateTime.Now,
Message = string.Join(" ", Enumerable.Range(5, random.Next(10, 50))
.Select(x => words[random.Next(0, maxword)])),
Contents = Enumerable.Range(5, random.Next(5, 10))
.Select(i => GetRandomEntry())
.ToList()
};
}
}

Data Items:

public class LogEntry : PropertyChangedBase
{
public DateTime DateTime { get; set; }

public int Index { get; set; }

public string Message { get; set; }
}

public class CollapsibleLogEntry: LogEntry
{
public List<LogEntry> Contents { get; set; }
}

PropertyChangedBase:

public class PropertyChangedBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(string propertyName)
{
Application.Current.Dispatcher.BeginInvoke((Action) (() =>
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
handler(this, new PropertyChangedEventArgs(propertyName));
}));
}
}

Log View Grid in WPF

You should to use the RowsDetailsTemplate for the grid:

<DataGrid Grid.Row="1" Margin="4,0,4,4" AutoGenerateColumns="False" ItemsSource="{Binding SomeItemsSource}" CanUserAddRows="False" AlternatingRowBackground="#FFCED9FF" RowDetailsTemplate="{StaticResource gridDetilsTemplate}">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding SomeValue}" Header="SOME TEXT" IsReadOnly="True"/>
...
</DataGrid.Columns>
</DataGrid>

And the template in your parent's dictionary resources (could be in any parent dictionary resources, or also you could write the template directly in the grid):

<Window.Resources>            
<DataTemplate x:Key="gridDetilsTemplate">
<Border BorderBrush="Black" BorderThickness="1,0,1,1" Margin="2,0,2,2" CornerRadius="0,0,3,3" Padding="4,0,0,0">
<TextBlock Text="{Binding SomeValue}"/>
</Border>
</DataTemplate>

I think this is what you need, a grid row details. Hope works.

Implementing a log viewer GUI with C# (or java)

ObjectListView (an open source wrapper around .NET WinForms ListView) does prefix searching and copying out of the box (plus lots of other neat things).

You can easily use a RowFormatter to give specific rows colors.

If you use the virtual list version, you can implement filtering without difficulty too. I've used it to apply filtering to lists of 100,000+ items and the performance is fine (on my mid-range laptop).

Full text searching would have to be implemented specifically. There are hooks for search-by-typing events.

All this is assuming you have some sort of reasonable LogEntry type model object to display. If you only have a series of lines of text, then you are on your own :)

What is a fast way to render a log view in WPF?

You can implement it very easily in WPF.

  1. Create an ObservableCollection of Log entities and bind to a ListBox.
  2. Give a DataTemplate for the ListBox.ItemTemplate.

*When running in real time you need either UI side or Data side virtualization
Check out my PaginatedObservableCollection so that the DataVirtualization will automatically function.

Displaying large text files with WPF C#

I have this file reading algorithm from a proof of concept application (which was also a log file viewer/diff viewer). The implementation requires C# 8.0 (.NET Core 3.x or .NET 5). I removed some indexing, cancellation etc. to remove noise and to show the core business of the algorithm.

It performs quite fast and compares very well with editors like Visual Code. It can't get much faster. To keep the UI responsive I highly recommend to use UI virtualization. If you implement UI virtualization, then the bottleneck will be the file reading operation. You can tweak the algorithm's performance by using different partition sizes (you can implement some smart partitioning to calculate them dynamically).

The key parts of the algorithm are

  • asynchronous implementation of Producer-Consumer pattern using Channel
  • partitioning of the source file into blocks of n bytes
  • parallel processing of file partitions (concurrent file reading)
  • merging the result document blocks and overlapping lines

DocumentBlock.cs

The result struct that holds the lines of a processed file partition.

public readonly struct DocumentBlock
{
public DocumentBlock(long rank, IList<string> content, bool hasOverflow)
{
this.Rank = rank;
this.Content = content;
this.HasOverflow = hasOverflow;
}

public long Rank { get; }
public IList<string> Content { get; }
public bool HasOverflow { get; }
}

ViewModel.cs

The entry point is the public ViewModel.ReadFileAsync member.

class ViewModel : INotifyPropertyChanged
{
public ViewModel() => this.DocumentBlocks = new ConcurrentBag<DocumentBlock>();

// TODO::Make reentrant
// (for example cancel running operations and
// lock/synchronize the method using a SemaphoreSlim)
public async Task ReadFileAsync(string filePath)
{
using var cancellationTokenSource = new CancellationTokenSource();

this.DocumentBlocks.Clear();
this.EndOfFileReached = false;

// Create the channel (Producer-Consumer implementation)
BoundedChannelOptions channeloptions = new BoundedChannelOptions(Environment.ProcessorCount)
{
FullMode = BoundedChannelFullMode.Wait,
AllowSynchronousContinuations = false,
SingleWriter = true
};

var channel = Channel.CreateBounded<(long PartitionLowerBound, long PartitionUpperBound)>(channeloptions);

// Create consumer threads
var tasks = new List<Task>();
for (int threadIndex = 0; threadIndex < Environment.ProcessorCount; threadIndex++)
{
Task task = Task.Run(async () => await ConsumeFilePartitionsAsync(channel.Reader, filePath, cancellationTokenSource));
tasks.Add(task);
}

// Produce document byte blocks
await ProduceFilePartitionsAsync(channel.Writer, cancellationTokenSource.Token);
await Task.WhenAll(tasks);
CreateFileContent();
this.DocumentBlocks.Clear();
}

private void CreateFileContent()
{
var document = new List<string>();
string overflowingLineContent = string.Empty;
bool isOverflowMergePending = false;

var orderedDocumentBlocks = this.DocumentBlocks.OrderBy(documentBlock => documentBlock.Rank);
foreach (var documentBlock in orderedDocumentBlocks)
{
if (isOverflowMergePending)
{
documentBlock.Content[0] += overflowingLineContent;
isOverflowMergePending = false;
}

if (documentBlock.HasOverflow)
{
overflowingLineContent = documentBlock.Content.Last();
documentBlock.Content.RemoveAt(documentBlock.Content.Count - 1);
isOverflowMergePending = true;
}

document.AddRange(documentBlock.Content);
}

this.FileContent = new ObservableCollection<string>(document);
}

private async Task ProduceFilePartitionsAsync(
ChannelWriter<(long PartitionLowerBound, long PartitionUpperBound)> channelWriter,
CancellationToken cancellationToken)
{
var iterationCount = 0;
while (!this.EndOfFileReached)
{
try
{
var partition = (iterationCount++ * ViewModel.PartitionSizeInBytes,
iterationCount * ViewModel.PartitionSizeInBytes);
await channelWriter.WriteAsync(partition, cancellationToken);
}
catch (OperationCanceledException)
{}
}
channelWriter.Complete();
}

private async Task ConsumeFilePartitionsAsync(
ChannelReader<(long PartitionLowerBound, long PartitionUpperBound)> channelReader,
string filePath,
CancellationTokenSource waitingChannelWritertCancellationTokenSource)
{
await using var file = File.OpenRead(filePath);
using var reader = new StreamReader(file);

await foreach ((long PartitionLowerBound, long PartitionUpperBound) filePartitionInfo
in channelReader.ReadAllAsync())
{
if (filePartitionInfo.PartitionLowerBound >= file.Length)
{
this.EndOfFileReached = true;
waitingChannelWritertCancellationTokenSource.Cancel();
return;
}

var documentBlockLines = new List<string>();
file.Seek(filePartitionInfo.PartitionLowerBound, SeekOrigin.Begin);
var filePartition = new byte[filePartitionInfo.PartitionUpperBound - partition.PartitionLowerBound];
await file.ReadAsync(filePartition, 0, filePartition.Length);

// Extract lines
bool isLastLineComplete = ExtractLinesFromFilePartition(documentBlockLines, filePartition);

bool documentBlockHasOverflow = !isLastLineComplete && file.Position != file.Length;
var documentBlock = new DocumentBlock(partition.PartitionLowerBound, documentBlockLines, documentBlockHasOverflow);
this.DocumentBlocks.Add(documentBlock);
}
}

private bool ExtractLinesFromFilePartition(byte[] filePartition, List<string> resultDocumentBlockLines)
{
bool isLineFound = false;
for (int bufferIndex = 0; bufferIndex < filePartition.Length; bufferIndex++)
{
isLineFound = false;
int lineBeginIndex = bufferIndex;
while (bufferIndex < filePartition.Length
&& !(isLineFound = ((char)filePartition[bufferIndex]).Equals('\n')))
{
bufferIndex++;
}

int lineByteCount = bufferIndex - lineBeginIndex;
if (lineByteCount.Equals(0))
{
documentBlockLines.Add(string.Empty);
}
else
{
var lineBytes = new byte[lineByteCount];
Array.Copy(filePartition, lineBeginIndex, lineBytes, 0, lineBytes.Length);
string lineContent = Encoding.UTF8.GetString(lineBytes).Trim('\r');
resultDocumentBlockLines.Add(lineContent);
}
}

return isLineFound;
}

protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "")
=> this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

public event PropertyChangedEventHandler PropertyChanged;
private const long PartitionSizeInBytes = 100000;
private bool EndOfFileReached { get; set; }
private ConcurrentBag<DocumentBlock> DocumentBlocks { get; }

private ObservableCollection<string> fileContent;
public ObservableCollection<string> FileContent
{
get => this.fileContent;
set
{
this.fileContent = value;
OnPropertyChanged();
}
}
}

To implement a very simple UI virtualization, this example uses a plain ListBox, where all mouse effects are removed from the ListBoxItem elements in order to get rid of the ListBox look and feel (a indetermintae progress indicator is highly recommended). You can enhance the example to allow multi-line text selection (e.g., to allow to copy text to the clipboard).

MainWindow.xaml

<Window>
<Window.DataContext>
<ViewModel />
</Window.DataContext>

<ListBox ScrollViewer.VerticalScrollBarVisibility="Visible"
ItemsSource="{Binding FileContent}"
Height="400" >
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</Window>

If you are more advanced, you can simply implement your own powerful document viewer e.g., by extending the VirtualizingPanel and using low-level text rendering. This allows you to increase performance in case you are interested in text search and highlighting (in this context stay far away from RichTextBox (or FlowDocument) as it is too slow).

At least you have a good performing text file reading algorithm you can use to generate the data source for your UI implementation.

If this viewer is not your main product, but a simple development tool to aid you in processing log files, I don't recommend to implement your own log file viewer. There are plenty of free and paid applications out there.



Related Topics



Leave a reply



Submit