diff --git a/.nuget/NuGet.Config b/.nuget/NuGet.Config new file mode 100644 index 0000000..67f8ea0 --- /dev/null +++ b/.nuget/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.nuget/NuGet.targets b/.nuget/NuGet.targets new file mode 100644 index 0000000..d566b12 --- /dev/null +++ b/.nuget/NuGet.targets @@ -0,0 +1,151 @@ + + + + $(MSBuildProjectDirectory)\..\ + + + false + + + false + + + true + + + false + + + + + + + + + + + $([System.IO.Path]::Combine($(SolutionDir), ".nuget")) + $([System.IO.Path]::Combine($(ProjectDir), "packages.config")) + + + + + $(SolutionDir).nuget + packages.config + + + + + $(NuGetToolsPath)\nuget.exe + @(PackageSource) + + "$(NuGetExePath)" + mono --runtime=v4.0.30319 $(NuGetExePath) + + $(TargetDir.Trim('\\')) + + -RequireConsent + + $(NuGetCommand) install "$(PackagesConfig)" -source "$(PackageSources)" $(RequireConsentSwitch) -solutionDir "$(SolutionDir) " + $(NuGetCommand) pack "$(ProjectPath)" -p Configuration=$(Configuration) -o "$(PackageOutputDir)" -symbols + + + + RestorePackages; + $(BuildDependsOn); + + + + + $(BuildDependsOn); + BuildPackage; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe new file mode 100644 index 0000000..4645f4b Binary files /dev/null and b/.nuget/nuget.exe differ diff --git a/LogWatch.Tests/Formats/CsvFormatTests.cs b/LogWatch.Tests/Formats/CsvFormatTests.cs new file mode 100644 index 0000000..9a97057 --- /dev/null +++ b/LogWatch.Tests/Formats/CsvFormatTests.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text; +using System.Threading; +using LogWatch.Formats; +using Xunit; + +namespace LogWatch.Tests.Formats { + public class CsvFormatTests { + [Fact] + public void ReadsSegment() { + var stream = CreateStream("2012-03-05 13:56:12;warn;Program;\"Test message\";TextException"); + + var format = new CsvLogFormat { + ReadHeader = false, + Delimeter = ';', + FieldCount = 5 + }; + + var subject = new ReplaySubject(); + + format.ReadSegments(subject, stream, CancellationToken.None).Wait(); + + subject.OnCompleted(); + + var segment = subject.ToEnumerable().FirstOrDefault(); + + Assert.Equal(0, segment.Offset); + Assert.Equal(stream.Length, segment.Length); + } + + [Fact] + public void GuessesOptionsFromHeader() { + var stream1 = CreateStream("time;level;message;logger;exception"); + var stream2 = CreateStream("time,level,message,logger,exception"); + + var format = new CsvLogFormat {ReadHeader = true}; + + format.ReadSegments(new Subject(), stream1, CancellationToken.None).Wait(); + + Assert.Equal(';', format.Delimeter); + Assert.Equal(5, format.FieldCount); + Assert.Equal(0, format.TimestampFieldIndex); + Assert.Equal(1, format.LevelFieldIndex); + Assert.Equal(2, format.MessageFieldIndex); + Assert.Equal(3, format.LoggerFieldIndex); + Assert.Equal(4, format.ExceptionFieldIndex); + + format = new CsvLogFormat {ReadHeader = true}; + format.ReadSegments(new Subject(), stream2, CancellationToken.None).Wait(); + + Assert.Equal(',', format.Delimeter); + } + + [Fact] + public void ReadsSegmentWithQuotedField() { + var stream = CreateStream( + "time;level;message;logger;exception\r\n" + + "2012-01-01 00:00:00;Info;\"Quoted \r\n field \r \n\";Program;Exception"); + + var format = new CsvLogFormat(); + var subject = new ReplaySubject(); + + format.ReadSegments(subject, stream, CancellationToken.None).Wait(); + + subject.OnCompleted(); + + var segment = subject.ToEnumerable().FirstOrDefault(); + + Assert.Equal(37, segment.Offset); + Assert.Equal(66, segment.Length); + } + + [Fact] + public void DeserializesRecord() { + var bytes = Encoding.UTF8.GetBytes("2012-01-01 01:39:40;Info;\"Quoted \r\n field \r \n\";Program;Exception"); + + var format = new CsvLogFormat { + Delimeter = ';', + FieldCount = 5, + TimestampFieldIndex = 0, + LevelFieldIndex = 1, + MessageFieldIndex = 2, + LoggerFieldIndex = 3, + ExceptionFieldIndex = 4 + }; + + var record = format.DeserializeRecord(new ArraySegment(bytes)); + + Assert.Equal(new DateTime(2012, 1, 1, 1, 39, 40), record.Timestamp); + Assert.Equal(LogLevel.Info, record.Level); + Assert.Equal("Quoted \r\n field \r \n", record.Message); + Assert.Equal("Program", record.Logger); + Assert.Equal("Exception", record.Exception); + } + + private static MemoryStream CreateStream(string content) { + var bytes = Encoding.UTF8.GetBytes(content); + var stream = new MemoryStream(bytes); + return stream; + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/Formats/Log4JXmlFormatTests.cs b/LogWatch.Tests/Formats/Log4JXmlFormatTests.cs new file mode 100644 index 0000000..13b9959 --- /dev/null +++ b/LogWatch.Tests/Formats/Log4JXmlFormatTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using LogWatch.Formats; +using Microsoft.Reactive.Testing; +using Xunit; + +namespace LogWatch.Tests.Formats { + public class Log4JXmlFormatTests { + [Fact] + public void ReadsSegments() { + var bytes = Encoding.UTF8.GetBytes( + "" + + " Istcua orojurf bysgurnl t." + + " " + + " " + + " " + + " " + + "" + + "" + + " Ebo ohow aco inldrfb pameenegy." + + " " + + " " + + " " + + " " + + ""); + + var stream = new MemoryStream(bytes); + var format = new Log4JXmlLogFormat(); + var testScheduler = new TestScheduler(); + var observer = testScheduler.CreateObserver(); + + var offset = format.ReadSegments(observer, stream, CancellationToken.None).Result; + + Assert.Equal(2, observer.Messages.Count); + + var segments = observer.Messages.Select(x => x.Value.Value).ToArray(); + + Assert.True(segments.Any(x => x.Offset == 0 && x.Length == 342)); + Assert.True(segments.Any(x => x.Offset == 342 && x.Length == 347)); + Assert.Equal(stream.Length, offset); + } + + [Fact] + public void DeserializesRecord() { + var bytes = Encoding.UTF8.GetBytes( + "" + + " Istcua orojurf bysgurnl t." + + " " + + " " + + " " + + " " + + " " + + ""); + + var format = new Log4JXmlLogFormat(); + + var record = format.DeserializeRecord(new ArraySegment(bytes)); + + Assert.Equal(LogLevel.Info, record.Level); + Assert.Equal("Istcua orojurf bysgurnl t.", record.Message); + Assert.Equal("ConsoleApplication1.Program", record.Logger); + Assert.Equal("1", record.Thread); + Assert.Equal("TestException", record.Exception); + Assert.Equal(new DateTime(2013, 02, 19, 13, 52, 47), record.Timestamp); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/LogWatch.Tests.csproj b/LogWatch.Tests/LogWatch.Tests.csproj new file mode 100644 index 0000000..691605e --- /dev/null +++ b/LogWatch.Tests/LogWatch.Tests.csproj @@ -0,0 +1,117 @@ + + + + + Debug + AnyCPU + {02D87BF3-0627-4D3F-90A5-85564F0A9E2D} + Library + Properties + LogWatch.Tests + LogWatch.Tests + v4.5 + 512 + ..\ + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + x86 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + x86 + + + + ..\packages\MvvmLightLibs.4.1.27.0\lib\net45\GalaSoft.MvvmLight.Extras.WPF45.dll + + + False + ..\packages\MvvmLightLibs.4.1.27.0\lib\net45\GalaSoft.MvvmLight.WPF45.dll + + + ..\packages\CommonServiceLocator.1.0\lib\NET35\Microsoft.Practices.ServiceLocation.dll + + + ..\packages\Rx-Testing.2.1.30214.0\lib\Net45-Full\Microsoft.Reactive.Testing.dll + + + + ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll + + + + + + ..\packages\Rx-Core.2.1.30214.0\lib\Net45\System.Reactive.Core.dll + + + ..\packages\Rx-Interfaces.2.1.30214.0\lib\Net45\System.Reactive.Interfaces.dll + + + ..\packages\Rx-Linq.2.1.30214.0\lib\Net45\System.Reactive.Linq.dll + + + ..\packages\Rx-PlatformServices.2.1.30214.0\lib\Net45\System.Reactive.PlatformServices.dll + + + False + ..\packages\Rx-Xaml.2.1.30214.0\lib\Net45\System.Reactive.Windows.Threading.dll + + + False + ..\packages\MvvmLightLibs.4.1.27.0\lib\net45\System.Windows.Interactivity.dll + + + + + + + + + + ..\packages\xunit.1.9.1\lib\net20\xunit.dll + + + + + + + + + + + + + + + + {bd78d588-dd85-4006-9952-3ff51b9390c7} + LogWatch + + + + + + + + + \ No newline at end of file diff --git a/LogWatch.Tests/Properties/AssemblyInfo.cs b/LogWatch.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..43dfca5 --- /dev/null +++ b/LogWatch.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("LogWatch.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("LogWatch.Tests")] +[assembly: AssemblyCopyright("Copyright © 2013")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("a31add10-a060-477e-a912-8bf0d614b1ce")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/LogWatch.Tests/RecordCollectionTests.cs b/LogWatch.Tests/RecordCollectionTests.cs new file mode 100644 index 0000000..940ad08 --- /dev/null +++ b/LogWatch.Tests/RecordCollectionTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using LogWatch.Features.Records; +using LogWatch.Sources; +using Microsoft.Reactive.Testing; +using Moq; +using Xunit; + +namespace LogWatch.Tests { + public class RecordCollectionTests { + private readonly RecordCollection collection; + private readonly Mock logSource; + private readonly Subject status = new Subject(); + private readonly TestScheduler testScheduler; + + public RecordCollectionTests() { + SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext()); + + this.testScheduler = new TestScheduler(); + this.logSource = new Mock(MockBehavior.Strict); + + this.logSource.SetupGet(x => x.Status).Returns(this.status.ObserveOn(this.testScheduler)); + + this.collection = new RecordCollection(this.logSource.Object) {Scheduler = this.testScheduler}; + this.collection.Initialize(); + } + + [Fact] + public void LoadsRecord() { + this.logSource + .Setup(x => x.ReadRecordAsync(1, CancellationToken.None)) + .Returns(Task.FromResult(new Record {Index = 1})); + + var record = this.collection.GetRecordAsync(1, CancellationToken.None).Result; + + Assert.Equal(1, record.Index); + } + + [Fact(Timeout = 30000)] + public void LoadsRequestedRecords() { + this.logSource + .Setup(x => x.ReadRecordAsync(It.IsAny(), It.IsAny())) + .Returns( + (int index, CancellationToken cancellationToken) => Task.FromResult(new Record {Index = index})); + + var record1 = this.collection[0]; + var record2 = this.collection[1]; + + var loaded = this.collection.LoadingRecordCount.Where(x => x == 0).FirstAsync().ToTask(); + + this.testScheduler.AdvanceBy(TimeSpan.FromMinutes(5).Ticks); + + loaded.Wait(); + + Assert.True(record1.IsLoaded); + Assert.True(record2.IsLoaded); + } + + [Fact] + public void UpdatesLoadingRecordCount() { + var completionSource = new TaskCompletionSource(); + + this.logSource + .Setup(x => x.ReadRecordAsync(It.IsAny(), It.IsAny())) + .Returns(completionSource.Task); + + var record = this.collection[1]; + + Assert.NotNull(record); + + var count = this.collection.LoadingRecordCount.FirstAsync().ToTask().Result; + + Assert.Equal(1, count); + } + + [Fact] + public void UpdatesStatus() { + this.status.OnNext(new LogSourceStatus(7, true, 20)); + + this.testScheduler.AdvanceBy(TimeSpan.FromMinutes(1).Ticks); + + Assert.Equal(7, this.collection.Count); + Assert.True(this.collection.IsProcessingSavedData); + Assert.Equal(20, this.collection.Progress); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/TestMessenger.cs b/LogWatch.Tests/TestMessenger.cs new file mode 100644 index 0000000..ad22791 --- /dev/null +++ b/LogWatch.Tests/TestMessenger.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using GalaSoft.MvvmLight.Messaging; + +namespace LogWatch.Tests { + public class TestMessenger : IMessenger { + private readonly Dictionary subscribers = new Dictionary(); + + public TestMessenger() { + this.SentMessages = new List(); + } + + public List SentMessages { get; set; } + + public void Register(object recipient, Action action) { + this.subscribers[typeof (TMessage)] = action; + } + + public void Register(object recipient, object token, Action action) { + this.subscribers[typeof (TMessage)] = action; + } + + public void Register( + object recipient, + object token, + bool receiveDerivedMessagesToo, + Action action) { + this.subscribers[typeof (TMessage)] = action; + } + + public void Register(object recipient, bool receiveDerivedMessagesToo, Action action) { + this.subscribers[typeof (TMessage)] = action; + } + + public void Send(TMessage message) { + this.SentMessages.Add(message); + this.InvokeSubscriber(message); + } + + public void Send(TMessage message) { + this.SentMessages.Add(message); + this.InvokeSubscriber(message); + } + + public void Send(TMessage message, object token) { + this.SentMessages.Add(message); + this.InvokeSubscriber(message); + } + + public void Unregister(object recipient) { + } + + public void Unregister(object recipient) { + } + + public void Unregister(object recipient, object token) { + } + + public void Unregister(object recipient, Action action) { + } + + public void Unregister(object recipient, object token, Action action) { + } + + private void InvokeSubscriber(TMessage message) { + foreach (var subscriber in this.subscribers) + if (subscriber.Key == message.GetType()) + subscriber.Value.DynamicInvoke(message); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/TestSynchronizationContext.cs b/LogWatch.Tests/TestSynchronizationContext.cs new file mode 100644 index 0000000..af7fc8f --- /dev/null +++ b/LogWatch.Tests/TestSynchronizationContext.cs @@ -0,0 +1,13 @@ +using System.Threading; + +namespace LogWatch.Tests { + public class TestSynchronizationContext : SynchronizationContext { + public override void Post(SendOrPostCallback d, object state) { + d(state); + } + + public override void Send(SendOrPostCallback d, object state) { + d(state); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/ViewModels/RecordDetailsViewModelTests.cs b/LogWatch.Tests/ViewModels/RecordDetailsViewModelTests.cs new file mode 100644 index 0000000..bf2345b --- /dev/null +++ b/LogWatch.Tests/ViewModels/RecordDetailsViewModelTests.cs @@ -0,0 +1,36 @@ +using GalaSoft.MvvmLight.Messaging; +using LogWatch.Features.RecordDetails; +using LogWatch.Features.SelectSource; +using LogWatch.Messages; +using Xunit; + +namespace LogWatch.Tests.ViewModels { + public class RecordDetailsViewModelTests { + private readonly TestMessenger messenger; + private readonly RecordDetailsViewModel viewModel; + + public RecordDetailsViewModelTests() { + this.messenger = new TestMessenger(); + + Messenger.OverrideDefault(this.messenger); + + this.viewModel = new RecordDetailsViewModel(); + } + + [Fact] + public void ShowsSelectedRecordDetails() { + this.messenger.Send(new RecordSelectedMessage(new Record {Index = 7})); + + Assert.NotNull(this.viewModel.Record); + Assert.Equal(7, this.viewModel.Record.Index); + } + } + + public class SelectSourceViewModelTests { + private SelectSourceViewModel viewModel; + + public SelectSourceViewModelTests() { + this.viewModel = new SelectSourceViewModel(); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/ViewModels/RecordsViewModelTests.cs b/LogWatch.Tests/ViewModels/RecordsViewModelTests.cs new file mode 100644 index 0000000..cdd9672 --- /dev/null +++ b/LogWatch.Tests/ViewModels/RecordsViewModelTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; +using GalaSoft.MvvmLight.Messaging; +using LogWatch.Features.Records; +using LogWatch.Messages; +using LogWatch.Sources; +using Microsoft.Reactive.Testing; +using Moq; +using Xunit; + +namespace LogWatch.Tests.ViewModels { + public class RecordsViewModelTests { + private readonly Mock logSource; + private readonly Subject status = new Subject(); + private readonly TestMessenger testMessenger; + private readonly TestScheduler testScheduler; + private readonly RecordsViewModel viewModel; + + public RecordsViewModelTests() { + SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext()); + + this.testScheduler = new TestScheduler(); + this.testMessenger = new TestMessenger(); + + Messenger.OverrideDefault(this.testMessenger); + + this.logSource = new Mock(MockBehavior.Strict); + + this.logSource + .SetupGet(x => x.Status) + .Returns(this.status.ObserveOn(this.testScheduler)); + + this.viewModel = new RecordsViewModel { + LogSourceInfo = new LogSourceInfo(this.logSource.Object, null, false, false), + Scheduler = this.testScheduler + }; + + this.viewModel.Initialize(); + } + + [Fact] + public void SelectsRecord() { + this.viewModel.SelectRecordCommand.Execute(this.viewModel.Records[1]); + + var message = this.testMessenger.SentMessages.OfType().Single(); + + Assert.Equal(1, message.Record.Index); + } + + [Fact] + public void NavigatesToRecord() { + var recordIndex = 0; + + this.viewModel.Navigated += (sender, args) => recordIndex = ((GoToIndexEventArgs) args).Index; + + this.testMessenger.Send(new NavigatedToRecordMessage(7)); + + Assert.Equal(7, recordIndex); + } + + [Fact] + public void ReportsCurrentRecordContext() { + RecordContextChangedMessage message = null; + + this.testMessenger.Register(this, x => message = x); + + var context = new Subject(); + + this.viewModel.RecordContext = context.ObserveOn(this.testScheduler); + + context.OnNext(new VisibleItemsInfo(new Record {Index = 7}, new Record {Index = 23})); + + this.testScheduler.AdvanceBy(TimeSpan.FromMinutes(5).Ticks); + + Assert.NotNull(message); + Assert.Equal(7, message.FromRecord.Index); + Assert.Equal(23, message.ToRecord.Index); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/ViewModels/StatsViewModelTests.cs b/LogWatch.Tests/ViewModels/StatsViewModelTests.cs new file mode 100644 index 0000000..eb7733e --- /dev/null +++ b/LogWatch.Tests/ViewModels/StatsViewModelTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; +using GalaSoft.MvvmLight.Messaging; +using LogWatch.Features.Stats; +using LogWatch.Messages; +using LogWatch.Sources; +using Microsoft.Reactive.Testing; +using Moq; +using Xunit; + +namespace LogWatch.Tests.ViewModels { + public class StatsViewModelTests { + private readonly Mock logSource; + private readonly TestMessenger messenger; + private readonly Subject records = new Subject(); + private readonly TestScheduler testScheduler; + private readonly StatsViewModel viewModel; + + public StatsViewModelTests() { + SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext()); + + this.testScheduler = new TestScheduler(); + this.messenger = new TestMessenger(); + this.logSource = new Mock(); + this.logSource.SetupGet(x => x.Records).Returns(this.records.ObserveOn(this.testScheduler)); + + Messenger.OverrideDefault(this.messenger); + + this.viewModel = new StatsViewModel { + Scheduler = this.testScheduler, + LogSourceInfo = new LogSourceInfo(this.logSource.Object, null, false, false) + }; + } + + [Fact] + public void NavigatesToRecord() { + this.viewModel.CollectCommand.Execute(null); + + this.records.OnNext(new Record {Index = 1, Level = LogLevel.Trace}); + this.records.OnNext(new Record {Index = 2, Level = LogLevel.Debug}); + this.records.OnNext(new Record {Index = 3, Level = LogLevel.Info}); + this.records.OnNext(new Record {Index = 4, Level = LogLevel.Warn}); + this.records.OnNext(new Record {Index = 5, Level = LogLevel.Error}); + this.records.OnNext(new Record {Index = 6, Level = LogLevel.Fatal}); + + this.testScheduler.AdvanceBy(TimeSpan.FromMinutes(5).Ticks); + + this.messenger.Send( + new RecordContextChangedMessage( + new Record {Index = 1}, + new Record {Index = 2})); + + this.viewModel.GoToNextCommand.Execute(LogLevel.Error); + + Assert.NotEmpty(this.messenger.SentMessages.OfType().Where(x => x.Index == 5)); + } + + [Fact] + public void CollectsStats() { + this.viewModel.CollectCommand.Execute(null); + + this.records.OnNext(new Record {Index = 1, Level = LogLevel.Trace}); + this.records.OnNext(new Record {Index = 2, Level = LogLevel.Debug}); + this.records.OnNext(new Record {Index = 3, Level = LogLevel.Info}); + this.records.OnNext(new Record {Index = 4, Level = LogLevel.Warn}); + this.records.OnNext(new Record {Index = 5, Level = LogLevel.Error}); + this.records.OnNext(new Record {Index = 6, Level = LogLevel.Fatal}); + + this.testScheduler.AdvanceBy(TimeSpan.FromMinutes(5).Ticks); + + Assert.Equal(1, this.viewModel.TraceCount); + Assert.Equal(1, this.viewModel.DebugCount); + Assert.Equal(1, this.viewModel.InfoCount); + Assert.Equal(1, this.viewModel.WarnCount); + Assert.Equal(1, this.viewModel.ErrorCount); + Assert.Equal(1, this.viewModel.FatalCount); + } + } +} \ No newline at end of file diff --git a/LogWatch.Tests/packages.config b/LogWatch.Tests/packages.config new file mode 100644 index 0000000..e87e0e9 --- /dev/null +++ b/LogWatch.Tests/packages.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LogWatch.sln b/LogWatch.sln new file mode 100644 index 0000000..1fa3009 --- /dev/null +++ b/LogWatch.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogWatch", "LogWatch\LogWatch.csproj", "{BD78D588-DD85-4006-9952-3FF51B9390C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogWatch.Tests", "LogWatch.Tests\LogWatch.Tests.csproj", "{02D87BF3-0627-4D3F-90A5-85564F0A9E2D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{D0AA1C1B-3669-4BDA-A818-647FE722EED6}" + ProjectSection(SolutionItems) = preProject + .nuget\NuGet.Config = .nuget\NuGet.Config + .nuget\NuGet.exe = .nuget\NuGet.exe + .nuget\NuGet.targets = .nuget\NuGet.targets + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BD78D588-DD85-4006-9952-3FF51B9390C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD78D588-DD85-4006-9952-3FF51B9390C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD78D588-DD85-4006-9952-3FF51B9390C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD78D588-DD85-4006-9952-3FF51B9390C7}.Release|Any CPU.Build.0 = Release|Any CPU + {02D87BF3-0627-4D3F-90A5-85564F0A9E2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02D87BF3-0627-4D3F-90A5-85564F0A9E2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02D87BF3-0627-4D3F-90A5-85564F0A9E2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02D87BF3-0627-4D3F-90A5-85564F0A9E2D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/LogWatch.sln.DotSettings b/LogWatch.sln.DotSettings new file mode 100644 index 0000000..dbfc98b --- /dev/null +++ b/LogWatch.sln.DotSettings @@ -0,0 +1,98 @@ + + <?xml version="1.0" encoding="utf-16"?><Profile name="Default"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><CSRemoveCodeRedundancies>True</CSRemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSUseVar><BehavourStyle>CAN_CHANGE_TO_IMPLICIT</BehavourStyle><LocalVariableStyle>ALWAYS_IMPLICIT</LocalVariableStyle><ForeachVariableStyle>ALWAYS_IMPLICIT</ForeachVariableStyle></CSUseVar><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XMLReformatCode>True</XMLReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings></Profile> + Default + Default + False + False + False + False + END_OF_LINE + END_OF_LINE + ALWAYS_REMOVE + ALWAYS_REMOVE + ALWAYS_REMOVE + ALWAYS_REMOVE + ALWAYS_REMOVE + ALWAYS_REMOVE + END_OF_LINE + END_OF_LINE + 1 + END_OF_LINE + False + False + False + ALWAYS_USE + LINE_BREAK + False + END_OF_LINE + True + WRAP_IF_LONG + WRAP_IF_LONG + True + 20 + OneStep + True + True + False + True + False + True + False + False + False + True + False + False + True + False + Automatic property + False + True + False + False + True + False + False + True + False + False + True + False + False + True + AD + CRC + DB + DN + FTP + GB + HSV + HTML + ID + IO + IP + MB + MD + NFS + OK + OS + PDF + RIR + RTF + SHA + SMB + SMTP + UI + WKID + WKT + XML + $object$_On$event$ + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + $object$_On$event$ + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True + C:\Users\shse\Documents\GitHub\LogWatch\LogWatch.sln.DotSettings + True + 1 \ No newline at end of file diff --git a/LogWatch/App.config b/LogWatch/App.config new file mode 100644 index 0000000..fe072da --- /dev/null +++ b/LogWatch/App.config @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LogWatch/App.ico b/LogWatch/App.ico new file mode 100644 index 0000000..20cfdd2 Binary files /dev/null and b/LogWatch/App.ico differ diff --git a/LogWatch/App.xaml b/LogWatch/App.xaml new file mode 100644 index 0000000..2adad57 --- /dev/null +++ b/LogWatch/App.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/LogWatch/App.xaml.cs b/LogWatch/App.xaml.cs new file mode 100644 index 0000000..34170a1 --- /dev/null +++ b/LogWatch/App.xaml.cs @@ -0,0 +1,135 @@ +using System; +using System.ComponentModel.Composition; +using System.ComponentModel.Composition.Hosting; +using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using FirstFloor.ModernUI.Windows.Controls; +using LogWatch.Features.SelectSource; +using LogWatch.Formats; +using LogWatch.Sources; +using Microsoft.Win32; + +namespace LogWatch { + public sealed partial class App { + public static readonly Action HandleException = + exception => { + if (Current.Dispatcher.CheckAccess()) + if (exception is AggregateException) + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + else + ExceptionDispatchInfo.Capture(exception).Throw(); + else + Current.Dispatcher.Invoke(() => HandleException(exception)); + }; + + public static readonly Func OpenFileDialog = + () => { + var dialog = new OpenFileDialog { + CheckFileExists = true, + CheckPathExists = true + }; + + if (dialog.ShowDialog() == true) + return dialog.FileName; + + return null; + }; + + public static readonly Action ErrorDialog = + message => ModernDialog.ShowMessage(message, "Error", MessageBoxButton.OK); + + public static readonly Action InfoDialog = + message => ModernDialog.ShowMessage(message, "Log Watch", MessageBoxButton.OK); + + private static readonly Func CollectStatsOnDemand = + filePath => new FileInfo(filePath).Length >= 10*1024*1024; + + private static readonly CompositionContainer Container = new CompositionContainer( + new AssemblyCatalog(typeof (App).Assembly)); + + public static readonly Func SelectFormat = stream => { + var formatSelector = new AutoLogFormatSelector(); + + Container.SatisfyImportsOnce(formatSelector); + + var logFormats = formatSelector.SelectFormat(stream).ToArray(); + + if (logFormats.Length == 0) + return new PlainTextLogFormat(); + + if (logFormats.Length == 1) + return logFormats[0].Value; + + var view = new SelectFormatView(); + var viewModel = view.ViewModel; + + foreach (var format in logFormats) + viewModel.Formats.Add(format); + + viewModel.Formats.Add( + new Lazy( + () => new PlainTextLogFormat(), + new LogFormatAttribute("Plain text"))); + + return view.ShowDialog() != true ? null : viewModel.Format; + }; + + public static readonly Func CreateFileLogSource = filePath => { + ILogFormat logFormat; + + using (var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + logFormat = SelectFormat(stream); + + if (logFormat == null) + return null; + + return new LogSourceInfo( + new FileLogSource(filePath, logFormat), + filePath, + false, + CollectStatsOnDemand(filePath)); + }; + + public static LogSourceInfo SourceInfo { get; set; } + + protected override void OnStartup(StartupEventArgs e) { + base.OnStartup(e); + + TaskScheduler.UnobservedTaskException += (sender, args) => HandleException(args.Exception); + + var filePath = e.Args.FirstOrDefault(); + + this.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + if (string.IsNullOrEmpty(filePath)) { + var view = new SelectSourceView(); + + if (view.ShowDialog() == true) + SourceInfo = view.ViewModel.Source; + } else + SourceInfo = CreateFileLogSource(filePath); + + if (SourceInfo == null) { + this.Shutdown(); + return; + } + + this.ShutdownMode = ShutdownMode.OnLastWindowClose; + + this.MainWindow = new ShellView(); + this.MainWindow.Closed += (sender, args) => SourceInfo.Source.Dispose(); + this.MainWindow.Show(); + } + + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { + ErrorDialog(e.Exception.ToString()); + + if (e.Exception is ApplicationException) + e.Handled = true; + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/RecordDetails/ExceptionHighlighter.cs b/LogWatch/Features/RecordDetails/ExceptionHighlighter.cs new file mode 100644 index 0000000..9553c94 --- /dev/null +++ b/LogWatch/Features/RecordDetails/ExceptionHighlighter.cs @@ -0,0 +1,117 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; + +namespace LogWatch.Features.RecordDetails { + public class ExceptionHighlighter : Freezable, IValueConverter { + private static readonly Regex Header = new Regex(@"^(?(?:\w+[.])+)(?\w+): (?.*)"); + + private static readonly Regex StackTraceItem = new Regex( + @"^\s+\w+\s+(?(?:\w+[.])+)(?[\w\[\],<>]+)[.](?<.ctor>\w+|[\w\[\],<>]+)[(](?[^)]+)?[)](?:\s+\w+\s+(?.+):\w+\s+(?\d+))?"); + + public static readonly DependencyProperty NavigateToUrlCommandProperty = + DependencyProperty.Register("NavigateToUrlCommand", typeof (ICommand), typeof (ExceptionHighlighter)); + + public Brush Namespace { get; set; } + public Brush Method { get; set; } + public Brush Message { get; set; } + public Brush MethodParameterType { get; set; } + public Brush Class { get; set; } + public Brush String { get; set; } + public Brush Line { get; set; } + + public ICommand NavigateToUrlCommand { + get { return (ICommand) this.GetValue(NavigateToUrlCommandProperty); } + set { this.SetValue(NavigateToUrlCommandProperty, value); } + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { + return this.CreateTextBlock((string) value); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { + throw new NotSupportedException(); + } + + private TextBlock CreateTextBlock(string exception) { + if (string.IsNullOrEmpty(exception)) + return new TextBlock {Text = exception}; + + var result = new TextBlock(); + + var lines = exception.Split(new[] {Environment.NewLine}, StringSplitOptions.None); + + foreach (var line in lines) { + var span = new Span(); + + if (!this.TryHeader(line, span) && !this.TryStackTraceItem(line, span)) + span.Inlines.Add(line); + + span.Inlines.Add(new LineBreak()); + result.Inlines.Add(span); + } + + return result; + } + + private bool TryStackTraceItem(string line, Span span) { + var match = StackTraceItem.Match(line); + if (match.Success) { + span.Inlines.Add(" at "); + span.Inlines.Add(new Run(match.Groups["NS"].Value) {Foreground = this.Namespace}); + span.Inlines.Add(new Run(match.Groups["Class"].Value) {Foreground = this.Class}); + span.Inlines.Add("."); + span.Inlines.Add(new Run(match.Groups["Method"].Value) {Foreground = this.Method}); + span.Inlines.Add("("); + span.Inlines.Add(new Run(match.Groups["Params"].Value)); + span.Inlines.Add(")"); + + var url = match.Groups["Url"].Value; + + if (!string.IsNullOrEmpty(url)) { + var hyperlink = new Hyperlink(new Run(Path.GetFileName(url))) {ToolTip = url}; + + BindingOperations.SetBinding(hyperlink, Hyperlink.CommandProperty, + new Binding("NavigateToUrlCommand") { + Source = this, + Mode = BindingMode.OneWay + }); + + hyperlink.CommandParameter = url; + + span.Inlines.Add(" in "); + span.Inlines.Add(hyperlink); + span.Inlines.Add(":line "); + span.Inlines.Add(new Run(match.Groups["Line"].Value) {Foreground = this.Line}); + } + return true; + } + return false; + } + + private bool TryHeader(string line, Span span) { + var match = Header.Match(line); + + if (match.Success) { + span.Inlines.Add(new Run(match.Groups["NS"].Value) {Foreground = this.Namespace}); + span.Inlines.Add(new Run(match.Groups["Class"].Value) {Foreground = this.Class}); + span.Inlines.Add(": "); + span.Inlines.Add(new Run(match.Groups["Message"].Value) {Foreground = this.Message}); + return true; + } + + return false; + } + + protected override Freezable CreateInstanceCore() { + return this; + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/RecordDetails/RecordDetailsView.xaml b/LogWatch/Features/RecordDetails/RecordDetailsView.xaml new file mode 100644 index 0000000..c7a4546 --- /dev/null +++ b/LogWatch/Features/RecordDetails/RecordDetailsView.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LogWatch/Features/RecordDetails/RecordDetailsView.xaml.cs b/LogWatch/Features/RecordDetails/RecordDetailsView.xaml.cs new file mode 100644 index 0000000..56981f6 --- /dev/null +++ b/LogWatch/Features/RecordDetails/RecordDetailsView.xaml.cs @@ -0,0 +1,7 @@ +namespace LogWatch.Features.RecordDetails { + public partial class RecordDetailsView { + public RecordDetailsView() { + this.InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/RecordDetails/RecordDetailsViewModel.cs b/LogWatch/Features/RecordDetails/RecordDetailsViewModel.cs new file mode 100644 index 0000000..adcb71f --- /dev/null +++ b/LogWatch/Features/RecordDetails/RecordDetailsViewModel.cs @@ -0,0 +1,95 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Windows; +using GalaSoft.MvvmLight; +using GalaSoft.MvvmLight.Command; +using LogWatch.Messages; + +namespace LogWatch.Features.RecordDetails { + public class RecordDetailsViewModel : ViewModelBase { + private Record record; + + public RecordDetailsViewModel() { + if (this.IsInDesignMode) + return; + + this.CopyAllCommand = new RelayCommand(this.CopyAll); + this.OpenFileCommand = new RelayCommand(url => { + try { + Process.Start(url); + } catch (FileNotFoundException) { + ErrorDialog("File not found: " + url); + } catch (Win32Exception exception) { + ErrorDialog(exception.Message); + } + }); + + this.MessengerInstance.Register(this, message => { this.Record = message.Record; }); + } + + public Action ErrorDialog { get; set; } + + public string Test { + get { + return + @"System.ArgumentException: Destination array is not long enough to copy all the items in the collection. Check array index and length." + + Environment.NewLine + + @" at System.ThrowHelper.ThrowArgumentException(ExceptionResource resource)" + Environment.NewLine + + @" at System.BitConverter.ToUInt16(Byte[] value, Int32 startIndex)" + Environment.NewLine + + @" at SharpCompress.Common.Zip.Headers.ZipFileEntry.LoadExtra(Byte[] extra)" + Environment.NewLine + + @" at SharpCompress.Common.Zip.Headers.LocalEntryHeader.Read(BinaryReader reader)" + + Environment.NewLine + + @" at SharpCompress.Common.Zip.ZipHeaderFactory.ReadHeader(UInt32 headerBytes, BinaryReader reader)" + + Environment.NewLine + + @" at SharpCompress.Common.Zip.SeekableZipHeaderFactory.GetLocalHeader(Stream stream, DirectoryEntryHeader directoryEntryHeader)" + + Environment.NewLine + + @" at SharpCompress.Common.Zip.SeekableZipFilePart.LoadLocalHeader()" + Environment.NewLine + + @" at SharpCompress.Common.Zip.SeekableZipFilePart.GetStream()" + Environment.NewLine + + @" at SharpCompress.Archive.Zip.ZipArchiveEntry.OpenEntryStream()" + Environment.NewLine + + @" at SharpCompress.Utility.Extract[TEntry,TVolume](TEntry entry, AbstractArchive`2 archive, Stream streamToWriteTo)" + + Environment.NewLine + + @" at SharpCompress.Archive.Zip.ZipArchiveEntry.WriteTo(Stream streamToWriteTo)" + + Environment.NewLine + + @" at Forensics.Worker.Indexers.ArchiveIndexer.ArchiveEntryInfo.get_FileStream() in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Indexers\ArchiveIndexer.cs:line 131" + + Environment.NewLine + + @" at Forensics.Worker.FileIndexContext.get_FileStream() in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\FileIndexContext.cs:line 93" + + Environment.NewLine + + @" at Forensics.Worker.Impl.WorkerTaskExecutorDomainProxy.<.ctor>b__4(FileIndexContext context) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Impl\WorkerTaskExecutorDomainProxy.cs:line 69" + + Environment.NewLine + + @" at Forensics.Worker.Impl.WorkerTaskExecutor.OnInitializingContext(FileIndexContext context) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Impl\WorkerTaskExecutor.cs:line 161" + + Environment.NewLine + + @" at Forensics.Worker.Impl.WorkerTaskExecutor.IndexFile(FileIndexContext context, IDictionary`2 footprints) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Impl\WorkerTaskExecutor.cs:line 176" + + Environment.NewLine + + @" at Forensics.Worker.Impl.WorkerTaskExecutor.<>c__DisplayClass13.b__d(FileIndexContext subcontext) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Impl\WorkerTaskExecutor.cs:line 295" + + Environment.NewLine + + @" at Forensics.Worker.FileIndexContext.IndexSubfile(IFileInfo subfileInfo, IFileContext subfileContext, String relativeParentId) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\FileIndexContext.cs:line 147" + + Environment.NewLine + + @" at Forensics.Worker.Indexers.ArchiveIndexer.IndexArchive(IFileIndexContext context, IArchive archive) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Indexers\ArchiveIndexer.cs:line 98" + + Environment.NewLine + + @" at Forensics.Worker.Indexers.ArchiveIndexer.Index(IFileIndexContext context) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Indexers\ArchiveIndexer.cs:line 67" + + Environment.NewLine + + @" at Forensics.Worker.Impl.WorkerTaskExecutor.IndexFile(FileIndexContext context, IDictionary`2 footprints) in x:\TeamCity\buildAgent\work\8712fdd031dc966\Forensics.Worker\Impl\WorkerTaskExecutor.cs:line 198" + + Environment.NewLine; + } + } + + public Record Record { + get { return this.record; } + set { this.Set(ref this.record, value); } + } + + public RelayCommand CopyAllCommand { get; set; } + public RelayCommand OpenFileCommand { get; set; } + + private void Set(ref T field, T newValue, [CallerMemberName] string propertyName = null) { + this.Set(propertyName, ref field, newValue, false); + } + + private void CopyAll() { + Clipboard.SetText(string.Concat(this.Record.Message, Environment.NewLine, this.Record.Exception)); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/RecordDetails/WindowsExplorerLinkNavigator.cs b/LogWatch/Features/RecordDetails/WindowsExplorerLinkNavigator.cs new file mode 100644 index 0000000..04e0682 --- /dev/null +++ b/LogWatch/Features/RecordDetails/WindowsExplorerLinkNavigator.cs @@ -0,0 +1,19 @@ +using System; +using System.Diagnostics; +using System.Windows; +using FirstFloor.ModernUI.Windows; +using FirstFloor.ModernUI.Windows.Navigation; + +namespace LogWatch.Features.RecordDetails { + public class WindowsExplorerLinkNavigator : ILinkNavigator { + public WindowsExplorerLinkNavigator() { + this.Commands = new CommandDictionary(); + } + + public void Navigate(Uri uri, FrameworkElement source, string parameter = null) { + Process.Start(uri.LocalPath, parameter); + } + + public CommandDictionary Commands { get; set; } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/AutoScrollToEndBehaviour.cs b/LogWatch/Features/Records/AutoScrollToEndBehaviour.cs new file mode 100644 index 0000000..f35dcd8 --- /dev/null +++ b/LogWatch/Features/Records/AutoScrollToEndBehaviour.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Reactive.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interactivity; + +namespace LogWatch.Features.Records { + public class AutoScrollToEndBehaviour : Behavior { + public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.Register( + "IsEnabled", typeof (bool), typeof (AutoScrollToEndBehaviour), new PropertyMetadata(true)); + + private bool isScrolling; + + private RecordCollection recordCollection; + private IDisposable subscription; + + public bool IsEnabled { + get { return (bool) this.GetValue(IsEnabledProperty); } + set { this.SetValue(IsEnabledProperty, value); } + } + + protected override void OnAttached() { + base.OnAttached(); + + this.recordCollection = this.AssociatedObject.ItemsSource as RecordCollection; + + var descriptor = DependencyPropertyDescriptor.FromProperty( + ItemsControl.ItemsSourceProperty, + typeof (Control)); + + descriptor.AddValueChanged(this.AssociatedObject, this.OnItemsSourceChanged); + + if (this.recordCollection != null) + this.Subscribe(); + } + + private void OnItemsSourceChanged(object sender, EventArgs args) { + if (this.recordCollection != null) + this.Unsubscribe(); + + this.recordCollection = this.AssociatedObject.ItemsSource as RecordCollection; + + if (this.recordCollection != null) + this.Subscribe(); + } + + private void Subscribe() { + this.AssociatedObject.AddHandler(ScrollViewer.ScrollChangedEvent, (RoutedEventHandler) this.OnScrollChanged); + + this.subscription = + Observable.FromEventPattern(this.recordCollection, "CollectionChanged") + .Throttle(TimeSpan.FromMilliseconds(700)) + .ObserveOnDispatcher() + .Where(_ => this.IsEnabled && !this.isScrolling) + .Subscribe(_ => this.OnObservableCollectionChanged()); + } + + private void OnScrollChanged(object sender, RoutedEventArgs routedEventArgs) { + var args = (ScrollChangedEventArgs) routedEventArgs; + + if (args.VerticalChange < 0) + this.IsEnabled = false; + } + + protected override void OnDetaching() { + base.OnDetaching(); + + if (this.recordCollection != null) + this.Unsubscribe(); + } + + private void Unsubscribe() { + this.AssociatedObject.RemoveHandler( + ScrollViewer.ScrollChangedEvent, + (RoutedEventHandler) this.OnScrollChanged); + + this.subscription.Dispose(); + } + + private async void OnObservableCollectionChanged() { + if (this.recordCollection.Count == 0) + return; + + this.isScrolling = true; + + await this.recordCollection.LoadingRecordCount.Where(x => x == 0).FirstAsync(); + + var lastRecord = + await this.recordCollection.GetRecordAsync(this.recordCollection.Count - 1, CancellationToken.None); + + if (Equals(this.AssociatedObject.Items.CurrentItem, lastRecord)) + return; + + if (this.AssociatedObject.Items.MoveCurrentTo(lastRecord)) + this.AssociatedObject.ScrollIntoView(lastRecord); + + this.isScrolling = false; + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/AutoSizeColumnBehaviour.cs b/LogWatch/Features/Records/AutoSizeColumnBehaviour.cs new file mode 100644 index 0000000..c204856 --- /dev/null +++ b/LogWatch/Features/Records/AutoSizeColumnBehaviour.cs @@ -0,0 +1,28 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interactivity; + +namespace LogWatch.Features.Records { + public class AutoSizeColumnBehaviour : Behavior { + protected override void OnAttached() { + base.OnAttached(); + + + this.AssociatedObject.AddHandler(ScrollViewer.ScrollChangedEvent, (RoutedEventHandler) OnScrollChanged); + } + + public int ColumnIndex { get; set; } + + void OnScrollChanged(object sender, RoutedEventArgs args) { + var gridView = this.AssociatedObject.View as GridView; + + if(gridView == null) + return; + + var column = gridView.Columns[ColumnIndex]; + + + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/FilteredRecordCollection.cs b/LogWatch/Features/Records/FilteredRecordCollection.cs new file mode 100644 index 0000000..bbcd623 --- /dev/null +++ b/LogWatch/Features/Records/FilteredRecordCollection.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using LogWatch.Sources; + +namespace LogWatch.Features.Records { + public class FilteredRecordCollection : RecordCollection { + private readonly Predicate filter; + private readonly ConcurrentDictionary recordIndexByLocalIndex = new ConcurrentDictionary(); + private readonly ConcurrentDictionary localIndexByRecordIndex = new ConcurrentDictionary(); + + private int count = -1; + + public FilteredRecordCollection(ILogSource logSource, Predicate filter) : base(logSource) { + this.filter = filter; + } + + public int GetLocalIndex(int recordIndex) { + int localIndex; + if (this.localIndexByRecordIndex.TryGetValue(recordIndex, out localIndex)) + return localIndex; + return -1; + } + + protected override IDisposable UpdateState() { + return this.LogSource.Records + .Buffer(TimeSpan.FromMilliseconds(2000), 2*1024, Scheduler) + .Where(batch => batch.Count > 0) + .Select(batch => new { + Filtered = batch.AsParallel().Where(record => this.filter(record)).ToArray(), + Progress = batch.Max(record => record.SourceStatus.Progress) + }) + .ObserveOnDispatcher() + .Subscribe(x => { + this.Progress = x.Progress; + + var batch = x.Filtered; + + if (batch.Length > 0) { + foreach (var record in batch) { + var filteredIndex = Interlocked.Increment(ref this.count); + this.recordIndexByLocalIndex.TryAdd(filteredIndex, record.Index); + this.localIndexByRecordIndex.TryAdd(record.Index, filteredIndex); + } + + this.Count += batch.Length; + this.OnCollectionReset(); + } + }); + } + + protected override async Task LoadRecordAsync(int index) { + int actualIndex; + + Record record; + + if (this.recordIndexByLocalIndex.TryGetValue(index, out actualIndex)) + record = await this.LogSource.ReadRecordAsync(actualIndex, this.CancellationToken) ?? new Record(); + else + record = new Record(); + + record.Index = index; + + return record; + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/GoToIndexEventArgs.cs b/LogWatch/Features/Records/GoToIndexEventArgs.cs new file mode 100644 index 0000000..fe15a89 --- /dev/null +++ b/LogWatch/Features/Records/GoToIndexEventArgs.cs @@ -0,0 +1,11 @@ +using System; + +namespace LogWatch.Features.Records { + public sealed class GoToIndexEventArgs : EventArgs { + public GoToIndexEventArgs(int index) { + this.Index = index; + } + + public int Index { get; private set; } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/IgnoreLinebreaksConverter.cs b/LogWatch/Features/Records/IgnoreLinebreaksConverter.cs new file mode 100644 index 0000000..8469aa3 --- /dev/null +++ b/LogWatch/Features/Records/IgnoreLinebreaksConverter.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; +using System.Windows.Data; + +namespace LogWatch.Features.Records { + public class IgnoreLinebreaksConverter : IValueConverter { + private const string Sub = " \u200B"; + private const string Lb = "\r\n"; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { + var s = (string) value; + return string.IsNullOrEmpty(s) ? s : Regex.Replace(s, Lb, Sub); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { + var s = (string) value; + return string.IsNullOrEmpty(s) ? s : Regex.Replace(s, Sub, Lb); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/LoggerToShortStringConverter.cs b/LogWatch/Features/Records/LoggerToShortStringConverter.cs new file mode 100644 index 0000000..850b424 --- /dev/null +++ b/LogWatch/Features/Records/LoggerToShortStringConverter.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Linq; + +namespace LogWatch.Features.Records { + public class LoggerToShortStringConverter : IValueConverter{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { + var logger = value as string; + + if (string.IsNullOrEmpty(logger)) + return null; + + return logger.Split('.').LastOrDefault(); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/RecordCollection.cs b/LogWatch/Features/Records/RecordCollection.cs new file mode 100644 index 0000000..8ec1047 --- /dev/null +++ b/LogWatch/Features/Records/RecordCollection.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GalaSoft.MvvmLight; +using LogWatch.Sources; + +namespace LogWatch.Features.Records { + public class RecordCollection : + ObservableObject, + IList, + IList, + IReadOnlyList, + INotifyCollectionChanged, + IDisposable { + private readonly ConcurrentDictionary cache; + private readonly ConcurrentQueue cacheSlots; + private readonly NotifyCollectionChangedEventArgs collectionResetArgs; + private readonly ReplaySubject loadingRecordCountSubject = new ReplaySubject(1); + private readonly ILogSource logSource; + + private readonly Subject requestedResocords = new Subject(); + private readonly CancellationTokenSource tokenSource; + private readonly TaskScheduler uiScheduler; + private int count; + + private bool isDisposed; + private bool isInitialized; + private bool isProcessingSavedData; + private IDisposable loadRecord; + private int loadingRecordCount; + private int progress; + private IDisposable updateState; + + public RecordCollection(ILogSource logSource) { + this.CacheSize = 512; + this.logSource = logSource; + this.tokenSource = new CancellationTokenSource(); + this.collectionResetArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); + this.cache = new ConcurrentDictionary(); + this.cacheSlots = new ConcurrentQueue(); + this.uiScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + this.Scheduler = System.Reactive.Concurrency.Scheduler.Default; + } + + protected ILogSource LogSource { + get { return this.logSource; } + } + + protected CancellationToken CancellationToken { + get { return this.tokenSource.Token; } + } + + public int CacheSize { get; set; } + + public int Progress { + get { return this.progress; } + set { this.Set(ref this.progress, value); } + } + + public bool IsProcessingSavedData { + get { return this.isProcessingSavedData; } + set { this.Set(ref this.isProcessingSavedData, value); } + } + + public IObservable LoadingRecordCount { + get { return this.loadingRecordCountSubject; } + } + + public IScheduler Scheduler { get; set; } + + public void Dispose() { + if (this.isDisposed) + return; + + this.isDisposed = true; + this.tokenSource.Cancel(); + this.updateState.Dispose(); + this.loadRecord.Dispose(); + + GC.SuppressFinalize(this); + } + + public int Add(object value) { + throw new NotSupportedException(); + } + + public bool Contains(object value) { + return true; + } + + void IList.Clear() { + throw new NotSupportedException(); + } + + public int IndexOf(object value) { + return this.IndexOf((Record) value); + } + + public void Insert(int index, object value) { + throw new NotSupportedException(); + } + + public void Remove(object value) { + throw new NotSupportedException(); + } + + void IList.RemoveAt(int index) { + throw new NotSupportedException(); + } + + object IList.this[int index] { + get { return this[index]; } + set { throw new NotSupportedException(); } + } + + bool IList.IsReadOnly { + get { return true; } + } + + public bool IsFixedSize { + get { return false; } + } + + public void CopyTo(Array array, int index) { + throw new NotSupportedException(); + } + + public int Count { + get { return this.count; } + set { this.Set(ref this.count, value); } + } + + public object SyncRoot { + get { return this; } + } + + public bool IsSynchronized { + get { return false; } + } + + public IEnumerator GetEnumerator() { + return Enumerable.Empty().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { + return this.GetEnumerator(); + } + + public void Add(Record item) { + throw new NotSupportedException(); + } + + void ICollection.Clear() { + throw new NotSupportedException(); + } + + public bool Contains(Record item) { + if (item == null) + return false; + return item.Index < this.Count; + } + + public void CopyTo(Record[] array, int arrayIndex) { + throw new NotSupportedException(); + } + + public bool Remove(Record item) { + throw new NotSupportedException(); + } + + int ICollection.Count { + get { return this.Count; } + } + + bool ICollection.IsReadOnly { + get { return true; } + } + + public int IndexOf(Record item) { + if (item == null) + return -1; + return item.Index; + } + + public void Insert(int index, Record item) { + throw new NotSupportedException(); + } + + void IList.RemoveAt(int index) { + throw new NotSupportedException(); + } + + public Record this[int index] { + get { + var isNew = false; + + var record = this.cache.GetOrAdd(index, key => { + isNew = true; + return new Record {Index = key}; + }); + + if (isNew) { + lock (this.cacheSlots) + this.cacheSlots.Enqueue(index); + this.RequestRecord(index); + } + + return record; + } + set { throw new NotSupportedException(); } + } + + public event NotifyCollectionChangedEventHandler CollectionChanged; + + public void Initialize() { + if (this.isInitialized) + throw new InvalidOperationException("Already initialized"); + + this.updateState = this.UpdateState(); + + this.loadRecord = + this.requestedResocords + .Buffer(TimeSpan.FromMilliseconds(300), this.Scheduler) + .Where(batch => batch.Count > 0) + .Subscribe(this.LoadRecordsAsync); + + this.isInitialized = true; + } + + protected virtual IDisposable UpdateState() { + var sampler = Observable + .Return(0L, this.Scheduler) + .Delay(TimeSpan.FromMilliseconds(100), this.Scheduler) + .Concat(Observable.Timer(TimeSpan.Zero, TimeSpan.FromSeconds(2), this.Scheduler)); + + return this.logSource.Status + .Sample(sampler) + .Where(x => x.Count > 0) + .ObserveOn(new SynchronizationContextScheduler(SynchronizationContext.Current)) + .Subscribe(this.OnStatusChanged); + } + + private void OnStatusChanged(LogSourceStatus status) { + this.Count = status.Count; + this.IsProcessingSavedData = status.IsProcessingSavedData; + this.Progress = status.Progress; + this.OnCollectionReset(); + } + + protected void OnCollectionReset() { + var handler = this.CollectionChanged; + if (handler != null) + handler(this, this.collectionResetArgs); + } + + private void Set(ref T field, T newValue, [CallerMemberName] string propertyName = null) { + this.Set(propertyName, ref field, newValue); + } + + private async void LoadRecordsAsync(IList batch) { + if (batch.Count > this.CacheSize) + for (var i = this.CacheSize; i < batch.Count; i++) + this.loadingRecordCountSubject.OnNext(Interlocked.Decrement(ref this.loadingRecordCount)); + + batch = batch.Reverse().Take(this.CacheSize).Reverse().ToArray(); + + foreach (var index in batch) { + var loaded = await this.LoadRecordAsync(index).ConfigureAwait(false); + + loaded.IsLoaded = true; + + var isUpdated = false; + + var record = this.cache.AddOrUpdate(index, loaded, (key, existed) => { + if (existed.IsLoaded) + return existed; + + existed.IsLoaded = true; + isUpdated = true; + + return existed; + }); + + if (isUpdated) + await Task.Factory.StartNew(() => { + record.Message = loaded.Message; + record.Level = loaded.Level; + record.Logger = loaded.Logger; + record.Exception = loaded.Exception; + record.Timestamp = loaded.Timestamp; + record.Attributes = loaded.Attributes; + }, this.CancellationToken, + TaskCreationOptions.None, + this.uiScheduler); + + this.loadingRecordCountSubject.OnNext(Interlocked.Decrement(ref this.loadingRecordCount)); + } + + this.CleanCache(); + } + + protected virtual async Task LoadRecordAsync(int index) { + return await this.logSource.ReadRecordAsync(index, this.tokenSource.Token) ?? + new Record {Index = index}; + } + + private void RequestRecord(int index) { + this.loadingRecordCountSubject.OnNext(Interlocked.Increment(ref this.loadingRecordCount)); + this.requestedResocords.OnNext(index); + } + + public async Task GetRecordAsync(int index, CancellationToken cancellationToken) { + var isNew = false; + + var record = this.cache.GetOrAdd(index, key => { + isNew = true; + return new Record {Index = key}; + }); + + if (isNew) + lock (this.cacheSlots) + this.cacheSlots.Enqueue(index); + + if (record.IsLoaded) + return record; + + record = await this.LoadRecordAsync(index, cancellationToken).ConfigureAwait(false); + + this.CleanCache(); + + return record; + } + + private async Task LoadRecordAsync(int index, CancellationToken cancellationToken) { + this.loadingRecordCountSubject.OnNext(Interlocked.Increment(ref this.loadingRecordCount)); + + var loaded = await this.logSource.ReadRecordAsync(index, cancellationToken).ConfigureAwait(false); + + if (loaded != null) + loaded.IsLoaded = true; + + var isUpdated = false; + + var record = this.cache.AddOrUpdate( + index, + loaded, + (key, existed) => { + if (existed.IsLoaded) + return existed; + + isUpdated = true; + + existed.IsLoaded = true; + + return existed; + }); + + if (isUpdated && loaded != null) + await Task.Factory.StartNew(() => { + record.Message = loaded.Message; + record.Level = loaded.Level; + record.Logger = loaded.Logger; + record.Exception = loaded.Exception; + record.Timestamp = loaded.Timestamp; + record.Attributes = loaded.Attributes; + }, cancellationToken, TaskCreationOptions.None, this.uiScheduler); + + this.loadingRecordCountSubject.OnNext(Interlocked.Decrement(ref this.loadingRecordCount)); + + return record; + } + + private void CleanCache() { + lock (this.cacheSlots) { + int index; + + while (this.cache.Count > this.CacheSize) + if (this.cacheSlots.TryDequeue(out index)) { + Record _; + this.cache.TryRemove(index, out _); + } else if (this.cacheSlots.Count == 0 && this.cache.Count > 0) + Debugger.Break(); + } + } + + ~RecordCollection() { + this.Dispose(); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/RecordsView.xaml b/LogWatch/Features/Records/RecordsView.xaml new file mode 100644 index 0000000..8a8b5e0 --- /dev/null +++ b/LogWatch/Features/Records/RecordsView.xaml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LogWatch/Features/Records/RecordsView.xaml.cs b/LogWatch/Features/Records/RecordsView.xaml.cs new file mode 100644 index 0000000..a09dc96 --- /dev/null +++ b/LogWatch/Features/Records/RecordsView.xaml.cs @@ -0,0 +1,31 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Linq; +using System.Windows.Input; +using LogWatch.Annotations; + +namespace LogWatch.Features.Records { + [UsedImplicitly] + public partial class RecordsView { + public RecordsView() { + this.InitializeComponent(); + } + + private void Records_OnSizeChanged(object sender, SizeChangedEventArgs e) { + var listView = (ListView) sender; + var gridView = (GridView) listView.View; + + var lastColumn = gridView.Columns.Last(); + + var newWidth = listView.ActualWidth - + gridView.Columns.Where(x => !Equals(x, lastColumn)).Sum(x => x.ActualWidth); + + lastColumn.Width = Math.Max(0, newWidth); + } + + public RecordsViewModel ViewModel { + get { return (RecordsViewModel) this.DataContext; } + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/RecordsViewModel.cs b/LogWatch/Features/Records/RecordsViewModel.cs new file mode 100644 index 0000000..5fbaaa2 --- /dev/null +++ b/LogWatch/Features/Records/RecordsViewModel.cs @@ -0,0 +1,80 @@ +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using GalaSoft.MvvmLight; +using GalaSoft.MvvmLight.Command; +using LogWatch.Annotations; +using LogWatch.Messages; +using LogWatch.Sources; + +namespace LogWatch.Features.Records { + public class RecordsViewModel : ViewModelBase { + private bool autoScroll; + private RecordCollection records; + + public RecordsViewModel() { + if (this.IsInDesignMode) + return; + + this.Scheduler = System.Reactive.Concurrency.Scheduler.Default; + this.SelectRecordCommand = new RelayCommand(this.SelectRecord); + this.MessengerInstance.Register(this, this.OnNavigateToRecord); + } + + public RecordCollection Records { + get { return this.records; } + private set { this.Set(ref this.records, value); } + } + + public bool AutoScroll { + get { return this.autoScroll; } + set { this.Set(ref this.autoScroll, value); } + } + + public RelayCommand SelectRecordCommand { get; set; } + + public IObservable RecordContext { + set { + if (value == null) + return; + + value.Where(x => x.FirstItem != null && x.LastItem != null) + .Sample(TimeSpan.FromMilliseconds(300), this.Scheduler) + .ObserveOn(new SynchronizationContextScheduler(SynchronizationContext.Current)) + .Subscribe( + info => + this.MessengerInstance.Send( + new RecordContextChangedMessage( + (Record) info.FirstItem, + (Record) info.LastItem))); + } + } + + public IScheduler Scheduler { get; set; } + public LogSourceInfo LogSourceInfo { get; set; } + + private void Set(ref T field, T newValue, [CallerMemberName] string propertyName = null) { + this.Set(propertyName, ref field, newValue, false); + } + + private void SelectRecord(Record record) { + this.MessengerInstance.Send(new RecordSelectedMessage(record)); + } + + public void Initialize() { + this.Records = new RecordCollection(this.LogSourceInfo.Source) {Scheduler = this.Scheduler}; + this.Records.Initialize(); + this.AutoScroll = this.LogSourceInfo.AutoScroll; + } + + private void OnNavigateToRecord(NavigatedToRecordMessage message) { + this.AutoScroll = false; + this.Navigated(this, new GoToIndexEventArgs(message.Index)); + } + + [UsedImplicitly] + public event EventHandler Navigated = (sender, args) => { }; + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/SelectItemByIndexAction.cs b/LogWatch/Features/Records/SelectItemByIndexAction.cs new file mode 100644 index 0000000..f3c64e1 --- /dev/null +++ b/LogWatch/Features/Records/SelectItemByIndexAction.cs @@ -0,0 +1,15 @@ +using System.Windows.Controls; +using System.Windows.Interactivity; + +namespace LogWatch.Features.Records { + public class SelectItemByIndexAction : TriggerAction { + protected override void Invoke(object parameter) { + var eventArgs = parameter as GoToIndexEventArgs; + + if (eventArgs != null) { + this.AssociatedObject.SelectedIndex = eventArgs.Index; + this.AssociatedObject.ScrollIntoView(this.AssociatedObject.SelectedItem); + } + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/VisibleItemsInfo.cs b/LogWatch/Features/Records/VisibleItemsInfo.cs new file mode 100644 index 0000000..6bb1de1 --- /dev/null +++ b/LogWatch/Features/Records/VisibleItemsInfo.cs @@ -0,0 +1,11 @@ +namespace LogWatch.Features.Records { + public sealed class VisibleItemsInfo { + public object FirstItem { get; private set; } + public object LastItem { get; private set; } + + public VisibleItemsInfo(object firstItem, object lastItem) { + this.FirstItem = firstItem; + this.LastItem = lastItem; + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Records/VisibleItemsInfoBehaviour.cs b/LogWatch/Features/Records/VisibleItemsInfoBehaviour.cs new file mode 100644 index 0000000..d80093e --- /dev/null +++ b/LogWatch/Features/Records/VisibleItemsInfoBehaviour.cs @@ -0,0 +1,77 @@ +using System; +using System.Reactive.Subjects; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interactivity; +using System.Windows.Media; +using System.Linq; + +namespace LogWatch.Features.Records { + public class VisibleItemsInfoBehaviour : Behavior { + public static readonly DependencyProperty VisibleItemsProperty = DependencyProperty.Register( + "VisibleItems", typeof (IObservable), typeof (VisibleItemsInfoBehaviour), + new PropertyMetadata(null)); + + private readonly Subject visibleItems = new Subject(); + private VirtualizingStackPanel virtualizingPanel; + + public IObservable VisibleItems { + get { return (IObservable) this.GetValue(VisibleItemsProperty); } + set { this.SetValue(VisibleItemsProperty, value); } + } + + protected override void OnAttached() { + base.OnAttached(); + + this.VisibleItems = this.visibleItems; + this.virtualizingPanel = this.GetVirtualizingStackPanel(this.AssociatedObject); + + this.AssociatedObject.AddHandler(ScrollViewer.ScrollChangedEvent, (RoutedEventHandler) this.OnScrollChanged); + } + + protected override void OnDetaching() { + base.OnDetaching(); + + this.AssociatedObject.RemoveHandler( + ScrollViewer.ScrollChangedEvent, + (RoutedEventHandler) this.OnScrollChanged); + } + + private VirtualizingStackPanel GetVirtualizingStackPanel(DependencyObject element) { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(element); i++) { + var child = VisualTreeHelper.GetChild(element, i) as FrameworkElement; + + if (child == null) + continue; + + if (child is VirtualizingStackPanel) + return child as VirtualizingStackPanel; + + var panel = this.GetVirtualizingStackPanel(child); + + if (panel != null) + return panel; + } + + return null; + } + + private void OnScrollChanged(object sender, RoutedEventArgs e) { + if (this.virtualizingPanel == null) + this.virtualizingPanel = this.GetVirtualizingStackPanel(this.AssociatedObject); + + if (this.virtualizingPanel == null) + return; + + var children = this.virtualizingPanel.Children; + + var firtsItem = children.OfType().Select(x => x.DataContext).FirstOrDefault(); + var lastItem = children.OfType() + .Where((element, i) => i <= this.virtualizingPanel.ViewportHeight) + .Select(x => x.DataContext) + .LastOrDefault(); + + this.visibleItems.OnNext(new VisibleItemsInfo(firtsItem, lastItem)); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Search/SearchView.xaml b/LogWatch/Features/Search/SearchView.xaml new file mode 100644 index 0000000..337347f --- /dev/null +++ b/LogWatch/Features/Search/SearchView.xaml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/LogWatch/Features/SelectSource/SelectSourceView.xaml.cs b/LogWatch/Features/SelectSource/SelectSourceView.xaml.cs new file mode 100644 index 0000000..de5255e --- /dev/null +++ b/LogWatch/Features/SelectSource/SelectSourceView.xaml.cs @@ -0,0 +1,11 @@ +namespace LogWatch.Features.SelectSource { + public partial class SelectSourceView { + public SelectSourceView() { + this.InitializeComponent(); + } + + public SelectSourceViewModel ViewModel { + get { return (SelectSourceViewModel) this.DataContext; } + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/SelectSource/SelectSourceViewModel.cs b/LogWatch/Features/SelectSource/SelectSourceViewModel.cs new file mode 100644 index 0000000..613f663 --- /dev/null +++ b/LogWatch/Features/SelectSource/SelectSourceViewModel.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Net; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GalaSoft.MvvmLight; +using GalaSoft.MvvmLight.Command; +using LogWatch.Formats; +using LogWatch.Sources; + +namespace LogWatch.Features.SelectSource { + public class SelectSourceViewModel : ViewModelBase { + private readonly TaskScheduler uiSchdeuler; + private bool? isLogSourceSelected; + private LogSourceInfo source; + + public SelectSourceViewModel() { + this.OpenFileCommand = new RelayCommand(this.OpenFile); + this.ListenNetworkCommand = new RelayCommand(this.ListenNetwork); + + if (this.IsInDesignMode) + return; + + this.uiSchdeuler = TaskScheduler.FromCurrentSynchronizationContext(); + + this.SelectLogFormat = stream => null; + this.SelectFile = () => null; + this.CreateFileLogSourceInfo = filePath => null; + this.SelectEndpoint = () => new IPEndPoint(IPAddress.Any, 13370); + } + + public Func SelectFile { get; set; } + public Func SelectEndpoint { get; set; } + public Func SelectLogFormat { get; set; } + public Func CreateFileLogSourceInfo { get; set; } + public Action HandleException { get; set; } + + public RelayCommand OpenFileCommand { get; set; } + public RelayCommand ListenNetworkCommand { get; set; } + + public bool? IsLogSourceSelected { + get { return this.isLogSourceSelected; } + set { this.Set(ref this.isLogSourceSelected, value); } + } + + public LogSourceInfo Source { + get { return this.source; } + set { this.Set(ref this.source, value); } + } + + private void ListenNetwork() { + var endPoint = this.SelectEndpoint(); + + var logSource = new UdpLogSource(endPoint, Path.GetTempFileName()) { + SelectLogFormat = stream => Task.Factory.StartNew( + () => this.SelectLogFormat(stream), + CancellationToken.None, + TaskCreationOptions.None, + this.uiSchdeuler) + }; + + this.Source = + new LogSourceInfo( + logSource, + string.Format("udp://{0}:{1}", endPoint.Address, endPoint.Port), + true, + false); + + this.IsLogSourceSelected = true; + } + + private void OpenFile() { + var filePath = this.SelectFile(); + + if (filePath != null) + this.OpenFile(filePath); + } + + private void OpenFile(string filePath) { + this.Source = this.CreateFileLogSourceInfo(filePath); + this.IsLogSourceSelected = true; + } + + private void Set(ref T field, T newValue, [CallerMemberName] string propertyName = null) { + this.Set(propertyName, ref field, newValue, false); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Stats/StatsView.xaml b/LogWatch/Features/Stats/StatsView.xaml new file mode 100644 index 0000000..e06f497 --- /dev/null +++ b/LogWatch/Features/Stats/StatsView.xaml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LogWatch/Features/Stats/StatsView.xaml.cs b/LogWatch/Features/Stats/StatsView.xaml.cs new file mode 100644 index 0000000..891aceb --- /dev/null +++ b/LogWatch/Features/Stats/StatsView.xaml.cs @@ -0,0 +1,7 @@ +namespace LogWatch.Features.Stats { + public partial class StatsView { + public StatsView() { + this.InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/LogWatch/Features/Stats/StatsViewModel.cs b/LogWatch/Features/Stats/StatsViewModel.cs new file mode 100644 index 0000000..4760ee0 --- /dev/null +++ b/LogWatch/Features/Stats/StatsViewModel.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using GalaSoft.MvvmLight; +using GalaSoft.MvvmLight.Command; +using LogWatch.Messages; +using LogWatch.Sources; + +namespace LogWatch.Features.Stats { + public class StatsViewModel : ViewModelBase { + private ImmutableList debugs = ImmutableList.Create(); + private ImmutableList errors = ImmutableList.Create(); + private ImmutableList fatals = ImmutableList.Create(); + private ImmutableList infos = ImmutableList.Create(); + private bool isColllecting; + private bool isProcessingSavedData; + private int lastRecordIndex; + private int progress; + private ImmutableList traces = ImmutableList.Create(); + private ImmutableList warns = ImmutableList.Create(); + + public StatsViewModel() { + if (this.IsInDesignMode) + return; + + this.Scheduler = System.Reactive.Concurrency.Scheduler.Default; + this.CollectCommand = new RelayCommand(this.Collect); + this.GoToNextCommand = new RelayCommand(this.GoToNext); + + this.MessengerInstance.Register(this, this.OnRecordContextChanged); + } + + public bool IsColllecting { + get { return this.isColllecting; } + set { this.Set(ref this.isColllecting, value); } + } + + public bool IsProcessingSavedData { + get { return this.isProcessingSavedData; } + set { this.Set(ref this.isProcessingSavedData, value); } + } + + public int Progress { + get { return this.progress; } + set { this.Set(ref this.progress, value); } + } + + public int TraceCount { get; set; } + public int DebugCount { get; set; } + public int InfoCount { get; set; } + public int WarnCount { get; set; } + public int ErrorCount { get; set; } + public int FatalCount { get; set; } + + public RelayCommand CollectCommand { get; set; } + public RelayCommand GoToNextCommand { get; set; } + public Action InfoDialog { get; set; } + public IScheduler Scheduler { get; set; } + public LogSourceInfo LogSourceInfo { get; set; } + + private void Set(ref T field, T newValue, [CallerMemberName] string propertyName = null) { + this.Set(propertyName, ref field, newValue, false); + } + + private void OnRecordContextChanged(RecordContextChangedMessage message) { + this.lastRecordIndex = message.ToRecord.Index; + } + + private void GoToNext(LogLevel level) { + ImmutableList indices; + + switch (level) { + case LogLevel.Trace: + indices = this.traces; + break; + + case LogLevel.Debug: + indices = this.debugs; + break; + + case LogLevel.Info: + indices = this.infos; + break; + + case LogLevel.Warn: + indices = this.warns; + break; + + case LogLevel.Error: + indices = this.errors; + break; + + case LogLevel.Fatal: + indices = this.fatals; + break; + + default: + return; + } + + if (indices.Count > 0) { + var recordIndex = indices.Find(index => index > this.lastRecordIndex); + + if (recordIndex == 0) { + this.InfoDialog("No records found"); + return; + } + + this.lastRecordIndex = recordIndex; + this.MessengerInstance.Send(new NavigatedToRecordMessage(recordIndex)); + } + } + + private void Collect() { + if (this.IsColllecting) + return; + + this.IsColllecting = true; + + this.LogSourceInfo.Source.Records + .Buffer(TimeSpan.FromSeconds(1), 8*1024, this.Scheduler) + .Where(x => x.Count > 0) + .Select(batch => { + var groupings = batch.GroupBy(x => x.Level).ToArray(); + + foreach (var grouping in groupings) + switch (grouping.Key) { + case LogLevel.Trace: + this.traces = this.traces.AddRange(grouping.Select(x => x.Index)); + break; + + case LogLevel.Debug: + this.debugs = this.debugs.AddRange(grouping.Select(x => x.Index)); + break; + + case LogLevel.Info: + this.infos = this.infos.AddRange(grouping.Select(x => x.Index)); + break; + + case LogLevel.Warn: + this.warns = this.warns.AddRange(grouping.Select(x => x.Index)); + break; + + case LogLevel.Error: + this.errors = this.errors.AddRange(grouping.Select(x => x.Index)); + break; + + case LogLevel.Fatal: + this.fatals = this.fatals.AddRange(grouping.Select(x => x.Index)); + break; + } + + this.RaisePropertyChanged(() => this.TraceCount); + this.RaisePropertyChanged(() => this.DebugCount); + this.RaisePropertyChanged(() => this.InfoCount); + this.RaisePropertyChanged(() => this.WarnCount); + this.RaisePropertyChanged(() => this.ErrorCount); + this.RaisePropertyChanged(() => this.FatalCount); + + return new { + groupings, + batch.Last().SourceStatus + }; + }) + .ObserveOn(new SynchronizationContextScheduler(SynchronizationContext.Current)) + .Subscribe(item => { + this.IsProcessingSavedData = item.SourceStatus.IsProcessingSavedData; + this.Progress = item.SourceStatus.Progress; + + foreach (var group in item.groupings) { + var count = group.Count(); + + switch (group.Key) { + case LogLevel.Trace: + this.TraceCount += count; + break; + + case LogLevel.Debug: + this.DebugCount += count; + break; + + case LogLevel.Info: + this.InfoCount += count; + break; + + case LogLevel.Warn: + this.WarnCount += count; + break; + + case LogLevel.Error: + this.ErrorCount += count; + break; + + case LogLevel.Fatal: + this.FatalCount += count; + break; + } + } + }); + } + } +} \ No newline at end of file diff --git a/LogWatch/Formats/AutoLogFormatSelector.cs b/LogWatch/Formats/AutoLogFormatSelector.cs new file mode 100644 index 0000000..8be67b9 --- /dev/null +++ b/LogWatch/Formats/AutoLogFormatSelector.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; + +namespace LogWatch.Formats { + public class AutoLogFormatSelector { + [ImportMany(typeof (ILogFormat))] + public IEnumerable> Formats { get; set; } + + public IEnumerable> SelectFormat(Stream stream) { + foreach (var format in this.Formats) { + stream.Position = 0; + + if (format.Value.CanRead(stream)) + yield return format; + } + } + } +} \ No newline at end of file diff --git a/LogWatch/Formats/CsvLogFormat.cs b/LogWatch/Formats/CsvLogFormat.cs new file mode 100644 index 0000000..f312a4b --- /dev/null +++ b/LogWatch/Formats/CsvLogFormat.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace LogWatch.Formats { + [LogFormat("CSV")] + public class CsvLogFormat : ILogFormat { + private bool isHeader = true; + + public CsvLogFormat() { + this.Encoding = Encoding.UTF8; + this.Delimeter = ';'; + this.Quote = '"'; + this.ReadHeader = true; + } + + public Tuple[] AttributeMappings { get; set; } + public Encoding Encoding { get; set; } + public char? Delimeter { get; set; } + public char Quote { get; set; } + public int? TimestampFieldIndex { get; set; } + public int? LoggerFieldIndex { get; set; } + public int? LevelFieldIndex { get; set; } + public int? MessageFieldIndex { get; set; } + public int? ExceptionFieldIndex { get; set; } + public int? FieldCount { get; set; } + + public Record DeserializeRecord(ArraySegment segment) { + var text = Encoding.UTF8.GetString(segment.Array, segment.Offset, segment.Count); + var fields = this.GetFields(text); + + var attributes = this.AttributeMappings; + + var record = new Record { + Logger = SafeGetFieldByIndex(fields, this.LoggerFieldIndex), + Level = this.GetLevel(fields), + Message = SafeGetFieldByIndex(fields, this.MessageFieldIndex), + Exception = SafeGetFieldByIndex(fields, this.ExceptionFieldIndex) + }; + + DateTime timestamp; + + if (DateTime.TryParse(SafeGetFieldByIndex(fields, this.TimestampFieldIndex), out timestamp)) + record.Timestamp = timestamp; + + if (attributes != null) + record.Attributes = attributes + .Select(x => new KeyValuePair(x.Item2, SafeGetFieldByIndex(fields, x.Item1))) + .ToArray(); + + return record; + } + + public async Task ReadSegments( + IObserver observer, + Stream stream, + CancellationToken cancellationToken) { + using (var reader = new StreamReader(stream, this.Encoding, false, 4096, true)) { + var offset = stream.Position; + + if (this.isHeader && ReadHeader) { + var header = await reader.ReadLineAsync(); + + if (header == null) + return offset; + + this.GuessDelimeter(header); + + var headerFields = this.GetFields(header); + + this.GuessFieldMappings(headerFields); + this.FieldCount = headerFields.Count; + + offset += this.Encoding.GetByteCount(header) + 2; + + this.isHeader = false; + } + + while (true) { + cancellationToken.ThrowIfCancellationRequested(); + + var length = await this.GetEndOffset(reader, cancellationToken); + + if (length == -1) + return offset; + + observer.OnNext(new RecordSegment(offset, length)); + + offset += length; + } + } + } + + public bool CanRead(Stream stream) { + using (var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, true)) { + var buffer = new char[4*1024]; + var count = reader.Read(buffer, 0, buffer.Length); + var text = new string(buffer, 0, count); + + if (string.IsNullOrWhiteSpace(text)) + return false; + + var lines = text.Split(new[] {'\n'}, 2, StringSplitOptions.RemoveEmptyEntries); + + var firstLine = lines.FirstOrDefault(); + + return firstLine != null && + firstLine.Split(new[] {';', ',', '|'}, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.ToLower()) + .Any(x => x == "level" || x == "message"); + } + } + + private void GuessDelimeter(string header) { + var variants = new[] {';', ','}; + + this.Delimeter = variants.OrderByDescending(delimeter => header.Split(delimeter).Length).First(); + } + + private LogLevel? GetLevel(IReadOnlyList fields) { + var level = SafeGetFieldByIndex(fields, this.LevelFieldIndex).ToLower(); + + switch (level) { + case "trace": + return LogLevel.Trace; + case "debug": + return LogLevel.Debug; + case "info": + return LogLevel.Info; + case "warn": + return LogLevel.Warn; + case "error": + return LogLevel.Error; + case "fatal": + return LogLevel.Fatal; + default: + return null; + } + } + + private static string SafeGetFieldByIndex(IReadOnlyList fields, int? index) { + if (index == null) + return string.Empty; + + return index < fields.Count ? fields[index.Value] : string.Empty; + } + + private void GuessFieldMappings(IEnumerable headerFields) { + var attributes = new List>(); + + foreach (var fieldWithIndex in headerFields.Select((field, index) => new {field, index})) + switch (fieldWithIndex.field.ToLower()) { + case "time": + this.TimestampFieldIndex = fieldWithIndex.index; + break; + + case "logger": + this.LoggerFieldIndex = fieldWithIndex.index; + break; + + case "level": + this.LevelFieldIndex = fieldWithIndex.index; + break; + + case "message": + this.MessageFieldIndex = fieldWithIndex.index; + break; + + case "exception": + this.ExceptionFieldIndex = fieldWithIndex.index; + break; + + default: + attributes.Add(Tuple.Create(fieldWithIndex.index, fieldWithIndex.field)); + break; + } + + if (attributes.Count > 0) + this.AttributeMappings = attributes.ToArray(); + } + + private IReadOnlyList GetFields(string record) { + var delimeter = this.Delimeter; + var fields = new List(); + var field = new StringBuilder(record.Length); + var quouted = false; + var length = record.Length; + + if (length >= 2) { + var ending = record.Substring(length - 2); + + switch (ending) { + case "\r": + case "\n": + length -= 1; + break; + + case "\r\n": + length -= 2; + break; + } + } + + for (var i = 0; i < length; i++) { + var c = record[i]; + + if (c == this.Quote) + quouted = !quouted; + + else if ((c == delimeter || c == '\r') && !quouted) { + TrimQuotes(field); + fields.Add(field.ToString()); + field.Clear(); + continue; + } + + field.Append(c); + } + + TrimQuotes(field); + + if (!quouted) + fields.Add(field.ToString()); + + return fields; + } + + private static void TrimQuotes(StringBuilder field) { + if (field.Length > 0 && field[0] == '"') + field.Remove(0, 1); + + if (field.Length > 0 && field[field.Length - 1] == '"') + field.Remove(field.Length - 1, 1); + } + + private async Task GetEndOffset(StreamReader reader, CancellationToken cancellationToken) { + var position = 0; + var newLineBytesCount = this.Encoding.GetByteCount(Environment.NewLine); + var expectedFieldCount = this.FieldCount; + var fieldCount = 0; + var quote = this.Quote; + var quoted = false; + var delimeter = this.Delimeter; + + while (true) { + cancellationToken.ThrowIfCancellationRequested(); + + var line = await reader.ReadLineAsync(); + + if (line == null) + break; + + for (var i = 0; i < line.Length; i++) { + var c = line[i]; + + if (c == quote) + quoted = !quoted; + + else if (c == delimeter && !quoted) + fieldCount++; + } + + position += this.Encoding.GetByteCount(line) + newLineBytesCount; + + if (!quoted) { + fieldCount++; + + if (fieldCount != expectedFieldCount) + return -1; + + return position; + } + } + + if (!quoted) { + fieldCount++; + + if (fieldCount != expectedFieldCount) + return -1; + + return position; + } + + return -1; + } + + public bool ReadHeader { get; set; } + } +} \ No newline at end of file diff --git a/LogWatch/Formats/ILogFormat.cs b/LogWatch/Formats/ILogFormat.cs new file mode 100644 index 0000000..16715e2 --- /dev/null +++ b/LogWatch/Formats/ILogFormat.cs @@ -0,0 +1,17 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace LogWatch.Formats { + public interface ILogFormat { + Record DeserializeRecord(ArraySegment segment); + + Task ReadSegments( + IObserver observer, + Stream stream, + CancellationToken cancellationToken); + + bool CanRead(Stream stream); + } +} \ No newline at end of file diff --git a/LogWatch/Formats/ILogFormatMetadata.cs b/LogWatch/Formats/ILogFormatMetadata.cs new file mode 100644 index 0000000..f55c55e --- /dev/null +++ b/LogWatch/Formats/ILogFormatMetadata.cs @@ -0,0 +1,5 @@ +namespace LogWatch.Formats { + public interface ILogFormatMetadata { + string Name { get; } + } +} \ No newline at end of file diff --git a/LogWatch/Formats/Log4JXmlLogFormat.cs b/LogWatch/Formats/Log4JXmlLogFormat.cs new file mode 100644 index 0000000..50fa701 --- /dev/null +++ b/LogWatch/Formats/Log4JXmlLogFormat.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using LogWatch.Util; + +namespace LogWatch.Formats { + [LogFormat("Log4J XML")] + public class Log4JXmlLogFormat : ILogFormat { + private static readonly byte[] EventStart = Encoding.UTF8.GetBytes(""); + + public Record DeserializeRecord(ArraySegment segment) { + try { + var text = Encoding.UTF8.GetString(segment.Array, segment.Offset, segment.Count) + .Replace(" x.Value).FirstOrDefault(), + Timestamp = JavaTimeStampToDateTime((long) element.Attribute("timestamp")), + Exception = properties == null ? null : + properties.Elements(ns + "data") + .Where(x => (string) x.Attribute("name") == "exception") + .Select(x => (string) x.Attribute("value")) + .FirstOrDefault(), + }; + } catch (XmlException) { + return null; + } + } + + public async Task ReadSegments( + IObserver observer, + Stream stream, + CancellationToken cancellationToken) { + var offset = stream.Position; + + var buffer = new byte[16*1024]; + + while (true) { + stream.Position = offset; + + var count = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + + if (count == 0) + return offset; + + var startOffsets = KmpUtil.GetOccurences(EventStart, buffer, cancellationToken); + var endOffsets = KmpUtil.GetOccurences(EventEnd, buffer, cancellationToken); + + if (startOffsets.Count == 0 || endOffsets.Count == 0) + return offset; + + var baseOffset = offset; + var segments = endOffsets.Zip( + startOffsets.Take(endOffsets.Count), + (end, start) => + new RecordSegment(baseOffset + start, (int) (end + EventEnd.Length - start))); + + foreach (var segment in segments) { + observer.OnNext(segment); + offset = segment.End; + } + } + } + + public bool CanRead(Stream stream) { + using (var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, true)) { + var buffer = new char[4*1024]; + var count = reader.Read(buffer, 0, buffer.Length); + var text = new string(buffer, 0, count); + + return !string.IsNullOrWhiteSpace(text) && text.Contains(" segment) { + return new Record { + Message = this.Encoding.GetString(segment.Array, segment.Offset, segment.Count) + }; + } + + public async Task ReadSegments( + IObserver observer, + Stream stream, + CancellationToken cancellationToken) { + var offset = stream.Position; + var newLineBytesCount = this.Encoding.GetByteCount(Environment.NewLine); + + using (var reader = new StreamReader(stream, this.Encoding, false, 4096, true)) + while (true) { + var line = await reader.ReadLineAsync(); + + if (line == null) + return offset; + + if (line.Length == 0) + continue; + + var length = this.Encoding.GetByteCount(line) + newLineBytesCount; + + observer.OnNext(new RecordSegment(offset, length)); + + offset += length; + } + } + + public bool CanRead(Stream stream) { + return true; + } + } +} \ No newline at end of file diff --git a/LogWatch/LogLevel.cs b/LogWatch/LogLevel.cs new file mode 100644 index 0000000..0aeea2b --- /dev/null +++ b/LogWatch/LogLevel.cs @@ -0,0 +1,10 @@ +namespace LogWatch { + public enum LogLevel : byte { + Trace, + Debug, + Info, + Warn, + Error, + Fatal + } +} \ No newline at end of file diff --git a/LogWatch/LogWatch.csproj b/LogWatch/LogWatch.csproj new file mode 100644 index 0000000..a97d2cf --- /dev/null +++ b/LogWatch/LogWatch.csproj @@ -0,0 +1,249 @@ + + + + + Debug + AnyCPU + {BD78D588-DD85-4006-9952-3FF51B9390C7} + WinExe + Properties + LogWatch + LogWatch + v4.5 + 512 + {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 4 + ..\ + true + + + x86 + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + x86 + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + App.ico + + + + False + ..\packages\ModernUI.WPF.1.0.2\lib\net45\FirstFloor.ModernUI.dll + + + ..\packages\MvvmLightLibs.4.1.27.0\lib\net45\GalaSoft.MvvmLight.Extras.WPF45.dll + + + ..\packages\MvvmLightLibs.4.1.27.0\lib\net45\GalaSoft.MvvmLight.WPF45.dll + + + + ..\packages\CommonServiceLocator.1.0\lib\NET35\Microsoft.Practices.ServiceLocation.dll + + + + + False + ..\packages\ModernUI.WPF.1.0.2\lib\net45\Microsoft.Windows.Shell.dll + + + + ..\packages\Microsoft.Bcl.Immutable.1.0.8-beta\lib\net45\System.Collections.Immutable.dll + + + + + ..\packages\Rx-Core.2.1.30214.0\lib\Net45\System.Reactive.Core.dll + + + ..\packages\Rx-Interfaces.2.1.30214.0\lib\Net45\System.Reactive.Interfaces.dll + + + ..\packages\Rx-Linq.2.1.30214.0\lib\Net45\System.Reactive.Linq.dll + + + ..\packages\Rx-PlatformServices.2.1.30214.0\lib\Net45\System.Reactive.PlatformServices.dll + + + ..\packages\Rx-Xaml.2.1.30214.0\lib\Net45\System.Reactive.Windows.Threading.dll + + + + + + + + + + 4.0 + + + + + + ..\packages\xunit.1.9.1\lib\net20\xunit.dll + + + + + MSBuild:Compile + Designer + + + + + + + + + SearchView.xaml + + + SelectFormatView.xaml + + + + + + + + + + + + + RecordDetailsView.xaml + + + + + + + + SelectSourceView.xaml + + + + + + + + + + + + + + + + RecordsView.xaml + + + StatsView.xaml + + + + + + + + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + MSBuild:Compile + Designer + + + App.xaml + Code + + + ShellView.xaml + Code + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + + + Code + + + True + True + Resources.resx + + + True + Settings.settings + True + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + + + + + + + + + + + \ No newline at end of file diff --git a/LogWatch/Messages/NavigatedToRecordMessage.cs b/LogWatch/Messages/NavigatedToRecordMessage.cs new file mode 100644 index 0000000..12c3400 --- /dev/null +++ b/LogWatch/Messages/NavigatedToRecordMessage.cs @@ -0,0 +1,9 @@ +namespace LogWatch.Messages { + public class NavigatedToRecordMessage { + public int Index { get; private set; } + + public NavigatedToRecordMessage(int index) { + Index = index; + } + } +} \ No newline at end of file diff --git a/LogWatch/Messages/RecordContextChangedMessage.cs b/LogWatch/Messages/RecordContextChangedMessage.cs new file mode 100644 index 0000000..e6319c9 --- /dev/null +++ b/LogWatch/Messages/RecordContextChangedMessage.cs @@ -0,0 +1,11 @@ +namespace LogWatch.Messages { + public sealed class RecordContextChangedMessage { + public RecordContextChangedMessage(Record fromRecord, Record toRecord) { + this.FromRecord = fromRecord; + this.ToRecord = toRecord; + } + + public Record FromRecord { get; private set; } + public Record ToRecord { get; private set; } + } +} \ No newline at end of file diff --git a/LogWatch/Messages/RecordSelectedMessage.cs b/LogWatch/Messages/RecordSelectedMessage.cs new file mode 100644 index 0000000..588acff --- /dev/null +++ b/LogWatch/Messages/RecordSelectedMessage.cs @@ -0,0 +1,9 @@ +namespace LogWatch.Messages { + public sealed class RecordSelectedMessage { + public Record Record { get; private set; } + + public RecordSelectedMessage(Record record) { + this.Record = record; + } + } +} \ No newline at end of file diff --git a/LogWatch/Properties/Annotations.cs b/LogWatch/Properties/Annotations.cs new file mode 100644 index 0000000..d548660 --- /dev/null +++ b/LogWatch/Properties/Annotations.cs @@ -0,0 +1,665 @@ +/* + * Copyright 2007-2012 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.ComponentModel; + +namespace LogWatch.Annotations +{ + /// + /// Indicates that marked element should be localized or not. + /// + /// + /// + /// [LocalizationRequiredAttribute(true)] + /// public class Foo + /// { + /// private string str = "my string"; // Warning: Localizable string + /// } + /// + /// + [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = true)] + public sealed class LocalizationRequiredAttribute : Attribute + { + /// + /// Initializes a new instance of the class with + /// set to . + /// + public LocalizationRequiredAttribute() : this(true) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// true if a element should be localized; otherwise, false. + public LocalizationRequiredAttribute(bool required) + { + Required = required; + } + + /// + /// Gets a value indicating whether a element should be localized. + /// true if a element should be localized; otherwise, false. + /// + [UsedImplicitly] public bool Required { get; private set; } + + /// + /// Returns whether the value of the given object is equal to the current . + /// + /// The object to test the value equality of. + /// + /// true if the value of the given object is equal to that of the current; otherwise, false. + /// + public override bool Equals(object obj) + { + var attribute = obj as LocalizationRequiredAttribute; + return attribute != null && attribute.Required == Required; + } + + /// + /// Returns the hash code for this instance. + /// + /// A hash code for the current . + public override int GetHashCode() + { + return base.GetHashCode(); + } + } + + /// + /// Indicates that the marked method builds string by format pattern and (optional) arguments. + /// Parameter, which contains format string, should be given in constructor. + /// The format string should be in -like form + /// + /// + /// + /// [StringFormatMethod("message")] + /// public void ShowError(string message, params object[] args) + /// { + /// //Do something + /// } + /// public void Foo() + /// { + /// ShowError("Failed: {0}"); // Warning: Non-existing argument in format string + /// } + /// + /// + [AttributeUsage(AttributeTargets.Constructor | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class StringFormatMethodAttribute : Attribute + { + /// + /// Initializes new instance of StringFormatMethodAttribute + /// + /// Specifies which parameter of an annotated method should be treated as format-string + public StringFormatMethodAttribute(string formatParameterName) + { + FormatParameterName = formatParameterName; + } + + /// + /// Gets format parameter name + /// + [UsedImplicitly] public string FormatParameterName { get; private set; } + } + + /// + /// Indicates that the function argument should be string literal and match one of the parameters + /// of the caller function. + /// For example, ReSharper annotates the parameter of . + /// + /// + /// + /// public void Foo(string param) + /// { + /// if (param == null) + /// throw new ArgumentNullException("par"); // Warning: Cannot resolve symbol + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class InvokerParameterNameAttribute : Attribute { } + + /// + /// Indicates that the method is contained in a type that implements + /// interface + /// and this method is used to notify that some property value changed. + /// + /// + /// The method should be non-static and conform to one of the supported signatures: + /// + /// NotifyChanged(string) + /// NotifyChanged(params string[]) + /// NotifyChanged{T}(Expression{Func{T}}) + /// NotifyChanged{T,U}(Expression{Func{T,U}}) + /// SetProperty{T}(ref T, T, string) + /// + /// + /// + /// + /// public class Foo : INotifyPropertyChanged + /// { + /// public event PropertyChangedEventHandler PropertyChanged; + /// + /// [NotifyPropertyChangedInvocator] + /// protected virtual void NotifyChanged(string propertyName) + /// {} + /// + /// private string _name; + /// public string Name + /// { + /// get { return _name; } + /// set + /// { + /// _name = value; + /// NotifyChanged("LastName"); // Warning + /// } + /// } + /// } + /// + /// Examples of generated notifications: + /// + /// NotifyChanged("Property") + /// NotifyChanged(() => Property) + /// NotifyChanged((VM x) => x.Property) + /// SetProperty(ref myField, value, "Property") + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class NotifyPropertyChangedInvocatorAttribute : Attribute + { + public NotifyPropertyChangedInvocatorAttribute() { } + public NotifyPropertyChangedInvocatorAttribute(string parameterName) + { + ParameterName = parameterName; + } + + [UsedImplicitly] public string ParameterName { get; private set; } + } + + /// + /// Indicates that the value of the marked element could be null sometimes, + /// so the check for null is necessary before its usage. + /// + /// + /// + /// [CanBeNull] + /// public object Test() + /// { + /// return null; + /// } + /// + /// public void UseTest() + /// { + /// var p = Test(); + /// var s = p.ToString(); // Warning: Possible 'System.NullReferenceException' + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Delegate | AttributeTargets.Field, AllowMultiple = false, Inherited = true)] + public sealed class CanBeNullAttribute : Attribute { } + + /// + /// Indicates that the value of the marked element could never be null + /// + /// + /// + /// [NotNull] + /// public object Foo() + /// { + /// return null; // Warning: Possible 'null' assignment + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Delegate | AttributeTargets.Field, AllowMultiple = false, Inherited = true)] + public sealed class NotNullAttribute : Attribute { } + + /// + /// Describes dependency between method input and output. + /// + /// + ///

Function Definition Table syntax:

+ /// + /// FDT ::= FDTRow [;FDTRow]* + /// FDTRow ::= Input => Output | Output <= Input + /// Input ::= ParameterName: Value [, Input]* + /// Output ::= [ParameterName: Value]* {halt|stop|void|nothing|Value} + /// Value ::= true | false | null | notnull | canbenull + /// + /// If method has single input parameter, it's name could be omitted.
+ /// Using halt (or void/nothing, which is the same) for method output means that the methos doesn't return normally.
+ /// canbenull annotation is only applicable for output parameters.
+ /// You can use multiple [ContractAnnotation] for each FDT row, or use single attribute with rows separated by semicolon.
+ ///
+ /// + /// + /// + /// [ContractAnnotation("=> halt")] + /// public void TerminationMethod() + /// + /// + /// [ContractAnnotation("halt <= condition: false")] + /// public void Assert(bool condition, string text) // Regular Assertion method + /// + /// + /// [ContractAnnotation("s:null => true")] + /// public bool IsNullOrEmpty(string s) // String.IsNullOrEmpty + /// + /// + /// // A method that returns null if the parameter is null, and not null if the parameter is not null + /// [ContractAnnotation("null => null; notnull => notnull")] + /// public object Transform(object data) + /// + /// + /// [ContractAnnotation("s:null=>false; =>true,result:notnull; =>false, result:null")] + /// public bool TryParse(string s, out Person result) + /// + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public sealed class ContractAnnotationAttribute : Attribute + { + public ContractAnnotationAttribute([NotNull] string fdt) : this (fdt, false) + { + } + + public ContractAnnotationAttribute([NotNull] string fdt, bool forceFullStates) + { + FDT = fdt; + ForceFullStates = forceFullStates; + } + + public string FDT { get; private set; } + public bool ForceFullStates { get; private set; } + } + + /// + /// Indicates that the value of the marked type (or its derivatives) + /// cannot be compared using '==' or '!=' operators and Equals() should be used instead. + /// However, using '==' or '!=' for comparison with null is always permitted. + /// + /// + /// + /// [CannotApplyEqualityOperator] + /// class NoEquality + /// { + /// } + /// + /// class UsesNoEquality + /// { + /// public void Test() + /// { + /// var ca1 = new NoEquality(); + /// var ca2 = new NoEquality(); + /// + /// if (ca1 != null) // OK + /// { + /// bool condition = ca1 == ca2; // Warning + /// } + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = true)] + public sealed class CannotApplyEqualityOperatorAttribute : Attribute { } + + /// + /// When applied to a target attribute, specifies a requirement for any type marked with + /// the target attribute to implement or inherit specific type or types. + /// + /// + /// + /// [BaseTypeRequired(typeof(IComponent)] // Specify requirement + /// public class ComponentAttribute : Attribute + /// {} + /// + /// [Component] // ComponentAttribute requires implementing IComponent interface + /// public class MyComponent : IComponent + /// {} + /// + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + [BaseTypeRequired(typeof(Attribute))] + public sealed class BaseTypeRequiredAttribute : Attribute + { + /// + /// Initializes new instance of BaseTypeRequiredAttribute + /// + /// Specifies which types are required + public BaseTypeRequiredAttribute(Type baseType) + { + BaseTypes = new[] { baseType }; + } + + /// + /// Gets enumerations of specified base types + /// + public Type[] BaseTypes { get; private set; } + } + + /// + /// Indicates that the marked symbol is used implicitly (e.g. via reflection, in external library), + /// so this symbol will not be marked as unused (as well as by other usage inspections) + /// + [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = true)] + public sealed class UsedImplicitlyAttribute : Attribute + { + [UsedImplicitly] public UsedImplicitlyAttribute() + : this(ImplicitUseKindFlags.Default, ImplicitUseTargetFlags.Default) { } + + [UsedImplicitly] + public UsedImplicitlyAttribute(ImplicitUseKindFlags useKindFlags, ImplicitUseTargetFlags targetFlags) + { + UseKindFlags = useKindFlags; + TargetFlags = targetFlags; + } + + [UsedImplicitly] public UsedImplicitlyAttribute(ImplicitUseKindFlags useKindFlags) + : this(useKindFlags, ImplicitUseTargetFlags.Default) { } + + [UsedImplicitly] public UsedImplicitlyAttribute(ImplicitUseTargetFlags targetFlags) + : this(ImplicitUseKindFlags.Default, targetFlags) { } + + [UsedImplicitly] public ImplicitUseKindFlags UseKindFlags { get; private set; } + + /// + /// Gets value indicating what is meant to be used + /// + [UsedImplicitly] public ImplicitUseTargetFlags TargetFlags { get; private set; } + } + + /// + /// Should be used on attributes and causes ReSharper + /// to not mark symbols marked with such attributes as unused (as well as by other usage inspections) + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class MeansImplicitUseAttribute : Attribute + { + [UsedImplicitly] public MeansImplicitUseAttribute() + : this(ImplicitUseKindFlags.Default, ImplicitUseTargetFlags.Default) { } + + [UsedImplicitly] + public MeansImplicitUseAttribute(ImplicitUseKindFlags useKindFlags, ImplicitUseTargetFlags targetFlags) + { + UseKindFlags = useKindFlags; + TargetFlags = targetFlags; + } + + [UsedImplicitly] public MeansImplicitUseAttribute(ImplicitUseKindFlags useKindFlags) + : this(useKindFlags, ImplicitUseTargetFlags.Default) + { + } + + [UsedImplicitly] public MeansImplicitUseAttribute(ImplicitUseTargetFlags targetFlags) + : this(ImplicitUseKindFlags.Default, targetFlags) { } + + [UsedImplicitly] public ImplicitUseKindFlags UseKindFlags { get; private set; } + + /// + /// Gets value indicating what is meant to be used + /// + [UsedImplicitly] public ImplicitUseTargetFlags TargetFlags { get; private set; } + } + + [Flags] + public enum ImplicitUseKindFlags + { + Default = Access | Assign | InstantiatedWithFixedConstructorSignature, + + /// + /// Only entity marked with attribute considered used + /// + Access = 1, + + /// + /// Indicates implicit assignment to a member + /// + Assign = 2, + + /// + /// Indicates implicit instantiation of a type with fixed constructor signature. + /// That means any unused constructor parameters won't be reported as such. + /// + InstantiatedWithFixedConstructorSignature = 4, + + /// + /// Indicates implicit instantiation of a type + /// + InstantiatedNoFixedConstructorSignature = 8, + } + + /// + /// Specify what is considered used implicitly when marked with or + /// + [Flags] + public enum ImplicitUseTargetFlags + { + Default = Itself, + + Itself = 1, + + /// + /// Members of entity marked with attribute are considered used + /// + Members = 2, + + /// + /// Entity marked with attribute and all its members considered used + /// + WithMembers = Itself | Members + } + + /// + /// This attribute is intended to mark publicly available API which should not be removed and so is treated as used. + /// + [MeansImplicitUse] + public sealed class PublicAPIAttribute : Attribute + { + public PublicAPIAttribute() { } + public PublicAPIAttribute(string comment) { } + } + + /// + /// Tells code analysis engine if the parameter is completely handled when the invoked method is on stack. + /// If the parameter is a delegate, indicates that delegate is executed while the method is executed. + /// If the parameter is an enumerable, indicates that it is enumerated while the method is executed. + /// + [AttributeUsage(AttributeTargets.Parameter, Inherited = true)] + public sealed class InstantHandleAttribute : Attribute { } + + + /// + /// Indicates that a method does not make any observable state changes. + /// The same as + /// + /// + /// + /// [Pure] + /// private int Multiply(int x, int y) + /// { + /// return x*y; + /// } + /// + /// public void Foo() + /// { + /// const int a=2, b=2; + /// Multiply(a, b); // Waring: Return value of pure method is not used + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method, Inherited = true)] + public sealed class PureAttribute : Attribute { } + + /// + /// Indicates that a parameter is a path to a file or a folder within a web project. + /// Path can be relative or absolute, starting from web root (~). + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class PathReferenceAttribute : Attribute + { + public PathReferenceAttribute() { } + + [UsedImplicitly] + public PathReferenceAttribute([PathReference] string basePath) + { + BasePath = basePath; + } + + [UsedImplicitly] public string BasePath { get; private set; } + } + + // ASP.NET MVC attributes + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is an MVC action. + /// If applied to a method, the MVC action name is calculated implicitly from the context. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method)] + public sealed class AspMvcActionAttribute : Attribute + { + [UsedImplicitly] public string AnonymousProperty { get; private set; } + + public AspMvcActionAttribute() { } + + public AspMvcActionAttribute(string anonymousProperty) + { + AnonymousProperty = anonymousProperty; + } + } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC araa. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AspMvcAreaAttribute : PathReferenceAttribute + { + [UsedImplicitly] public string AnonymousProperty { get; private set; } + + [UsedImplicitly] public AspMvcAreaAttribute() { } + + public AspMvcAreaAttribute(string anonymousProperty) + { + AnonymousProperty = anonymousProperty; + } + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is an MVC controller. + /// If applied to a method, the MVC controller name is calculated implicitly from the context. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method)] + public sealed class AspMvcControllerAttribute : Attribute + { + [UsedImplicitly] public string AnonymousProperty { get; private set; } + + public AspMvcControllerAttribute() { } + + public AspMvcControllerAttribute(string anonymousProperty) + { + AnonymousProperty = anonymousProperty; + } + } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC Master. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AspMvcMasterAttribute : Attribute { } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC model type. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AspMvcModelTypeAttribute : Attribute { } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is an MVC partial view. + /// If applied to a method, the MVC partial view name is calculated implicitly from the context. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method)] + public sealed class AspMvcPartialViewAttribute : PathReferenceAttribute { } + + /// + /// ASP.NET MVC attribute. Allows disabling all inspections for MVC views within a class or a method. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public sealed class AspMvcSupressViewErrorAttribute : Attribute { } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC display template. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AspMvcDisplayTemplateAttribute : Attribute { } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC editor template. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class AspMvcEditorTemplateAttribute : Attribute { } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is an MVC view. + /// If applied to a method, the MVC view name is calculated implicitly from the context. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method)] + public sealed class AspMvcViewAttribute : PathReferenceAttribute { } + + /// + /// ASP.NET MVC attribute. When applied to a parameter of an attribute, + /// indicates that this parameter is an MVC action name. + /// + /// + /// + /// [ActionName("Foo")] + /// public ActionResult Login(string returnUrl) + /// { + /// ViewBag.ReturnUrl = Url.Action("Foo"); // OK + /// return RedirectToAction("Bar"); // Error: Cannot resolve action + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] + public sealed class AspMvcActionSelectorAttribute : Attribute { } + + // Razor attributes + + /// + /// Razor attribute. Indicates that a parameter or a method is a Razor section. + /// Use this attribute for custom wrappers similar to + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method, Inherited = true)] + public sealed class RazorSectionAttribute : Attribute { } + +} \ No newline at end of file diff --git a/LogWatch/Properties/AssemblyInfo.cs b/LogWatch/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6cf8755 --- /dev/null +++ b/LogWatch/Properties/AssemblyInfo.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Windows; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("LogWatch")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("LogWatch")] +[assembly: AssemblyCopyright("Copyright © 2013")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +//In order to begin building localizable applications, set +//CultureYouAreCodingWith in your .csproj file +//inside a . For example, if you are using US english +//in your source files, set the to en-US. Then uncomment +//the NeutralResourceLanguage attribute below. Update the "en-US" in +//the line below to match the UICulture setting in the project file. + +//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] + + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] + + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/LogWatch/Properties/Resources.Designer.cs b/LogWatch/Properties/Resources.Designer.cs new file mode 100644 index 0000000..e0bfe8b --- /dev/null +++ b/LogWatch/Properties/Resources.Designer.cs @@ -0,0 +1,62 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.18010 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace LogWatch.Properties { + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if ((resourceMan == null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LogWatch.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/LogWatch/Properties/Resources.resx b/LogWatch/Properties/Resources.resx new file mode 100644 index 0000000..af7dbeb --- /dev/null +++ b/LogWatch/Properties/Resources.resx @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LogWatch/Properties/Settings.Designer.cs b/LogWatch/Properties/Settings.Designer.cs new file mode 100644 index 0000000..64cf08c --- /dev/null +++ b/LogWatch/Properties/Settings.Designer.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.18010 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace LogWatch.Properties { + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default { + get { + return defaultInstance; + } + } + } +} diff --git a/LogWatch/Properties/Settings.settings b/LogWatch/Properties/Settings.settings new file mode 100644 index 0000000..033d7a5 --- /dev/null +++ b/LogWatch/Properties/Settings.settings @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/LogWatch/Record.cs b/LogWatch/Record.cs new file mode 100644 index 0000000..672c485 --- /dev/null +++ b/LogWatch/Record.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using GalaSoft.MvvmLight; +using LogWatch.Sources; + +namespace LogWatch { + public sealed class Record : ObservableObject { + private string exception; + private LogLevel? level; + private string logger; + private string message; + private string thread; + private DateTime? timestamp; + public int Index { get; set; } + + public DateTime? Timestamp { + get { return this.timestamp; } + set { this.Set(ref this.timestamp, value); } + } + + public LogLevel? Level { + get { return this.level; } + set { this.Set(ref this.level, value); } + } + + public string Logger { + get { return this.logger; } + set { this.Set(ref this.logger, value); } + } + + public string Thread { + get { return this.thread; } + set { this.Set(ref this.thread, value); } + } + + public string Message { + get { return this.message; } + set { this.Set(ref this.message, value); } + } + + public string Exception { + get { return this.exception; } + set { this.Set(ref this.exception, value); } + } + + public bool IsLoaded { get; set; } + + public LogSourceStatus SourceStatus { get; set; } + + public KeyValuePair[] Attributes { get; set; } + + private void Set(ref T field, T newValue, [CallerMemberName] string propertyName = null) { + this.Set(propertyName, ref field, newValue); + } + } +} \ No newline at end of file diff --git a/LogWatch/RecordSegment.cs b/LogWatch/RecordSegment.cs new file mode 100644 index 0000000..1766df8 --- /dev/null +++ b/LogWatch/RecordSegment.cs @@ -0,0 +1,15 @@ +namespace LogWatch { + public struct RecordSegment { + public readonly int Length; + public readonly long Offset; + + public RecordSegment(long offset, int length) : this() { + Offset = offset; + Length = length; + } + + public long End { + get { return Offset + Length; } + } + } +} \ No newline at end of file diff --git a/LogWatch/ShellView.xaml b/LogWatch/ShellView.xaml new file mode 100644 index 0000000..8a24872 --- /dev/null +++ b/LogWatch/ShellView.xaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LogWatch/ShellView.xaml.cs b/LogWatch/ShellView.xaml.cs new file mode 100644 index 0000000..918bfeb --- /dev/null +++ b/LogWatch/ShellView.xaml.cs @@ -0,0 +1,7 @@ +namespace LogWatch { + public partial class ShellView { + public ShellView() { + this.InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/LogWatch/ShellViewModel.cs b/LogWatch/ShellViewModel.cs new file mode 100644 index 0000000..3225f54 --- /dev/null +++ b/LogWatch/ShellViewModel.cs @@ -0,0 +1,6 @@ +using GalaSoft.MvvmLight; + +namespace LogWatch { + public class ShellViewModel : ViewModelBase { + } +} \ No newline at end of file diff --git a/LogWatch/Sources/FileLogSource.cs b/LogWatch/Sources/FileLogSource.cs new file mode 100644 index 0000000..e54b5ee --- /dev/null +++ b/LogWatch/Sources/FileLogSource.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; +using System.Threading.Tasks; +using LogWatch.Formats; +using LogWatch.Util; + +namespace LogWatch.Sources { + public class FileLogSource : ILogSource { + private readonly AutoResetEventAsync fileChanged = new AutoResetEventAsync(false); + private readonly string filePath; + private readonly ILogFormat logFormat; + private readonly Lazy recordsStream; + private readonly SemaphoreSlim recordsStreamSemaphore = new SemaphoreSlim(1); + private readonly ConcurrentDictionary segments; + private readonly Stream segmentsStream; + private readonly ReplaySubject status = new ReplaySubject(1); + private readonly CancellationTokenSource tokenSource = new CancellationTokenSource(); + private readonly FileSystemWatcher watcher; + + private bool isDisposed; + private long streamLength; + + public FileLogSource(string filePath, ILogFormat logFormat) { + this.BufferSize = 64*1024; + + this.filePath = filePath; + + this.watcher = this.CreateFileWatcher(); + this.watcher.Changed += (sender, args) => this.FileChanged(); + this.watcher.EnableRaisingEvents = true; + + this.segments = new ConcurrentDictionary(); + this.segmentsStream = OpenSegmentsStream(filePath); + this.recordsStream = new Lazy(this.OpenRecordStream); + + this.logFormat = logFormat; + + Task.Run(() => this.LoadSegmentsAsync(this.tokenSource.Token), this.tokenSource.Token); + } + + public int BufferSize { get; set; } + + public IObservable Records { + get { + return Observable + .Create( + (observer, cancellationToken) => this.ObserveRecordsAsync(observer, cancellationToken)) + .SubscribeOn(ThreadPoolScheduler.Instance); + } + } + + public void Dispose() { + if (this.isDisposed) + return; + + this.isDisposed = true; + this.tokenSource.Cancel(); + this.status.Dispose(); + this.watcher.Dispose(); + if (this.recordsStream.IsValueCreated) + this.recordsStream.Value.Dispose(); + } + + public IObservable Status { + get { return this.status; } + } + + public async Task ReadRecordAsync(int index, CancellationToken cancellationToken) { + var stream = this.recordsStream.Value; + + RecordSegment segment; + + if (this.segments.TryGetValue(index, out segment)) { + await this.recordsStreamSemaphore.WaitAsync(cancellationToken); + + var buffer = new byte[segment.Length]; + int count; + + try { + stream.Position = segment.Offset; + count = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + } finally { + this.recordsStreamSemaphore.Release(); + } + + //if (count != segment.Length) + // throw new ApplicationException("Segment mapping failed"); + + var record = this.logFormat.DeserializeRecord(new ArraySegment(buffer)); + + if (record == null) + return null; + + record.Index = index; + + var currentLength = Interlocked.Read(ref this.streamLength); + + record.SourceStatus = new LogSourceStatus( + index + 1, + segment.End < currentLength, + (int) (100.0*segment.End/currentLength)); + + return record; + } + + return null; + } + + private static FileStream OpenSegmentsStream(string filePath) { + return File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + + public void FileChanged() { + this.fileChanged.Set(); + } + + private async Task LoadSegmentsAsync(CancellationToken cancellationToken) { + using (var observer = new Subject()) { + observer.Subscribe(segment => { + var index = this.segments.Count; + + if (this.segments.TryAdd(index, segment)) { + var length = this.streamLength; + + this.status.OnNext(new LogSourceStatus( + index + 1, + segment.End < length, + (int) (100.0*this.segmentsStream.Position/length))); + } + }); + + while (true) { + Interlocked.Exchange(ref this.streamLength, this.segmentsStream.Length); + + var startPosition = await this.logFormat.ReadSegments(observer, this.segmentsStream, cancellationToken); + + await this.fileChanged.WaitAsync(cancellationToken); + + this.segmentsStream.Position = startPosition; + } + } + } + + private async Task ObserveRecordsAsync(IObserver observer, CancellationToken cancellationToken) { + var statusChanged = new AutoResetEventAsync(true); + + var index = 0; + + using (this.status.Subscribe(_ => statusChanged.Set())) + while (true) { + await statusChanged.WaitAsync(cancellationToken); + + for (; index < this.segments.Count; index++) { + cancellationToken.ThrowIfCancellationRequested(); + + var currentIndex = index; + var record = await this.ReadRecordAsync(currentIndex, cancellationToken); + + if (record != null) + observer.OnNext(record); + } + } + } + + private FileSystemWatcher CreateFileWatcher() { + var fileName = Path.GetFileName(this.filePath); + + if (fileName == null) + throw new ArgumentException("filePath"); + + var directoryName = Path.GetDirectoryName(this.filePath); + + return new FileSystemWatcher(directoryName ?? @".", fileName); + } + + private Stream OpenRecordStream() { + return File.Open(this.filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + + ~FileLogSource() { + this.Dispose(); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/LogWatch/Sources/ILogSource.cs b/LogWatch/Sources/ILogSource.cs new file mode 100644 index 0000000..f68f54b --- /dev/null +++ b/LogWatch/Sources/ILogSource.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace LogWatch.Sources { + public interface ILogSource : IDisposable { + IObservable Records { get; } + IObservable Status { get; } + + Task ReadRecordAsync(int index, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/LogWatch/Sources/LogSourceInfo.cs b/LogWatch/Sources/LogSourceInfo.cs new file mode 100644 index 0000000..e17db56 --- /dev/null +++ b/LogWatch/Sources/LogSourceInfo.cs @@ -0,0 +1,15 @@ +namespace LogWatch.Sources { + public sealed class LogSourceInfo { + public LogSourceInfo(ILogSource source, string name, bool autoScroll, bool collectStatsOnDemand) { + this.CollectStatsOnDemand = collectStatsOnDemand; + this.AutoScroll = autoScroll; + this.Name = name; + this.Source = source; + } + + public ILogSource Source { get; private set; } + public string Name { get; private set; } + public bool AutoScroll { get; private set; } + public bool CollectStatsOnDemand { get; private set; } + } +} \ No newline at end of file diff --git a/LogWatch/Sources/LogSourceStatus.cs b/LogWatch/Sources/LogSourceStatus.cs new file mode 100644 index 0000000..32aa890 --- /dev/null +++ b/LogWatch/Sources/LogSourceStatus.cs @@ -0,0 +1,13 @@ +namespace LogWatch.Sources { + public struct LogSourceStatus { + public readonly int Count; + public readonly bool IsProcessingSavedData; + public readonly int Progress; + + public LogSourceStatus(int count, bool isProcessingSavedData, int progress) { + this.Count = count; + this.IsProcessingSavedData = isProcessingSavedData; + this.Progress = progress; + } + } +} \ No newline at end of file diff --git a/LogWatch/Sources/UdpLogSource.cs b/LogWatch/Sources/UdpLogSource.cs new file mode 100644 index 0000000..b11791b --- /dev/null +++ b/LogWatch/Sources/UdpLogSource.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LogWatch.Formats; + +namespace LogWatch.Sources { + public class UdpLogSource : ILogSource { + private readonly string dumpFilePath; + private readonly FileStream dumpStream; + private readonly IPEndPoint endPointToListen; + private readonly ManualResetEvent fileSourceCreated = new ManualResetEvent(false); + private readonly UdpClient udpClient; + private FileLogSource fileSource; + private bool isDisposed; + + public UdpLogSource(IPEndPoint endPoint, string dumpFilePath) { + this.endPointToListen = endPoint; + this.dumpFilePath = dumpFilePath; + this.udpClient = new UdpClient(endPoint) {Client = {ReceiveTimeout = 1000}}; + this.dumpStream = OpenDumpStream(dumpFilePath); + + this.RecordsSeparator = Encoding.UTF8.GetBytes(Environment.NewLine); + + Task.Factory.StartNew( + this.Listen, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + public Func> SelectLogFormat { get; set; } + + public byte[] RecordsSeparator { get; set; } + + public void Dispose() { + if (this.isDisposed) + return; + + this.isDisposed = true; + + this.udpClient.Close(); + this.dumpStream.Dispose(); + + GC.SuppressFinalize(this); + } + + public IObservable Records { + get { + return Observable.Defer(() => { + this.fileSourceCreated.WaitOne(); + return this.fileSource.Records; + }).SubscribeOn(TaskPoolScheduler.Default); + } + } + + public IObservable Status { + get { + return Observable.Defer(() => { + this.fileSourceCreated.WaitOne(); + return this.fileSource.Status; + }).SubscribeOn(TaskPoolScheduler.Default); + } + } + + public event Action Error = exception => { }; + + public Task ReadRecordAsync(int index, CancellationToken cancellationToken) { + var logSource = this.fileSource; + + if (logSource == null) + return null; + + return logSource.ReadRecordAsync(index, cancellationToken); + } + + private static FileStream OpenDumpStream(string dumpFilePath) { + return new FileStream( + dumpFilePath, + FileMode.Open, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.WriteThrough); + } + + private void Listen() { + var shouldInitializeLogSource = true; + + while (!this.isDisposed) { + var endPoint = this.endPointToListen; + + byte[] data; + + try { + data = this.udpClient.Receive(ref endPoint); + } catch (ObjectDisposedException) { + break; + } catch (SocketException exception) { + if (exception.SocketErrorCode == SocketError.TimedOut) + continue; + + if (exception.SocketErrorCode == SocketError.Interrupted) + break; + + this.Error(exception); + break; + } + + if (data.Length > 0) { + this.dumpStream.Write(data, 0, data.Length); + this.dumpStream.Write(this.RecordsSeparator, 0, this.RecordsSeparator.Length); + this.dumpStream.Flush(); + + if (this.fileSource != null) + this.fileSource.FileChanged(); + + if (shouldInitializeLogSource) { + shouldInitializeLogSource = false; + this.InitializeLogSource(); + } + } + } + } + + ~UdpLogSource() { + this.Dispose(); + } + + private async void InitializeLogSource() { + ILogFormat logFormat; + + using (var fileStream = File.Open(this.dumpFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + logFormat = await this.SelectLogFormat(fileStream); + + this.fileSource = new FileLogSource(this.dumpFilePath, logFormat); + this.fileSourceCreated.Set(); + } + } +} \ No newline at end of file diff --git a/LogWatch/Styles/CompactMenu.xaml b/LogWatch/Styles/CompactMenu.xaml new file mode 100644 index 0000000..c14c967 --- /dev/null +++ b/LogWatch/Styles/CompactMenu.xaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LogWatch/Styles/CompactWindow.xaml b/LogWatch/Styles/CompactWindow.xaml new file mode 100644 index 0000000..c67ec88 --- /dev/null +++ b/LogWatch/Styles/CompactWindow.xaml @@ -0,0 +1,284 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/LogWatch/Util/AutoResetEventAsync.cs b/LogWatch/Util/AutoResetEventAsync.cs new file mode 100644 index 0000000..3058e8f --- /dev/null +++ b/LogWatch/Util/AutoResetEventAsync.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace LogWatch.Util { + /// + /// Asynchronous version of + /// + public sealed class AutoResetEventAsync { + private static readonly Task Completed = Task.FromResult(true); + + private readonly ConcurrentQueue> handlers = + new ConcurrentQueue>(); + + private int isSet; + + /// + /// Initializes a new instance of the class with a Boolean value indicating whether to set the intial state to signaled. + /// + /// true to set the initial state signaled; false to set the initial state to nonsignaled. + public AutoResetEventAsync(bool initialState) { + this.isSet = initialState ? 1 : 0; + } + + /// + /// Sets the state of the event to signaled, allowing one or more waiting continuations to proceed. + /// + public void Set() { + if (!this.TrySet()) + return; + + TaskCompletionSource handler; + + // Notify first alive handler + while (this.handlers.TryDequeue(out handler)) + if (CheckIfAlive(handler)) // Flag check + lock (handler) { + if (!CheckIfAlive(handler)) + continue; + + if (this.TryReset()) + handler.SetResult(true); + else + this.handlers.Enqueue(handler); + + break; + } + } + + /// + /// Try to switch the state to signaled from not signaled + /// + /// + /// true if suceeded, false if failed + /// + private bool TrySet() { + return Interlocked.CompareExchange(ref this.isSet, 1, 0) == 0; + } + + /// + /// Waits for a signal asynchronously + /// + public Task WaitAsync() { + return this.WaitAsync(CancellationToken.None); + } + + /// + /// Waits for a signal asynchronously + /// + /// + /// A to observe while waiting for a signal. + /// + /// + /// The was canceled. + /// + public Task WaitAsync(CancellationToken cancellationToken) { + // Short path + if (this.TryReset()) + return Completed; + + cancellationToken.ThrowIfCancellationRequested(); + + // Wait for a signal + var handler = new TaskCompletionSource(false); + + this.handlers.Enqueue(handler); + + if (CheckIfAlive(handler)) // Flag check + lock (handler) + if (CheckIfAlive(handler) && this.TryReset()) { + handler.SetResult(true); + return handler.Task; + } + + cancellationToken.Register(() => { + if (CheckIfAlive(handler)) // Flag check + lock (handler) + if (CheckIfAlive(handler)) + handler.SetCanceled(); + }); + + return handler.Task; + } + + private static bool CheckIfAlive(TaskCompletionSource handler) { + return handler.Task.Status == TaskStatus.WaitingForActivation; + } + + private bool TryReset() { + return Interlocked.CompareExchange(ref this.isSet, 0, 1) == 1; + } + } +} \ No newline at end of file diff --git a/LogWatch/Util/DialogResultBehaviour.cs b/LogWatch/Util/DialogResultBehaviour.cs new file mode 100644 index 0000000..634821c --- /dev/null +++ b/LogWatch/Util/DialogResultBehaviour.cs @@ -0,0 +1,50 @@ +using System; +using System.Windows; +using System.Windows.Interactivity; + +namespace LogWatch.Util { + public class DialogResultBehaviour : Behavior { + public static readonly DependencyProperty DialogResultProperty = DependencyProperty.Register( + "DialogResult", typeof (bool?), typeof (DialogResultBehaviour), new PropertyMetadata(OnDialogResultChanged)); + + private bool isSourceInitialized; + + public DialogResultBehaviour() { + this.CloseDialogWhenDialogResultHasBeenSet = true; + } + + public bool? DialogResult { + get { return (bool?) this.GetValue(DialogResultProperty); } + set { this.SetValue(DialogResultProperty, value); } + } + + public bool CloseDialogWhenDialogResultHasBeenSet { get; set; } + + protected override void OnAttached() { + base.OnAttached(); + this.AssociatedObject.SourceInitialized += this.OnSourceInitialized; + } + + private void OnSourceInitialized(object sender, EventArgs e) { + this.AssociatedObject.DialogResult = this.DialogResult; + this.isSourceInitialized = true; + this.TryCloseDialog(); + } + + private void TryCloseDialog() { + if (this.CloseDialogWhenDialogResultHasBeenSet && this.DialogResult != null) + this.AssociatedObject.Close(); + } + + private static void OnDialogResultChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { + ((DialogResultBehaviour) d).OnDialogResultChanged(); + } + + private void OnDialogResultChanged() { + if (this.isSourceInitialized) { + this.AssociatedObject.DialogResult = this.DialogResult; + this.TryCloseDialog(); + } + } + } +} \ No newline at end of file diff --git a/LogWatch/Util/KmpUtil.cs b/LogWatch/Util/KmpUtil.cs new file mode 100644 index 0000000..96aa21f --- /dev/null +++ b/LogWatch/Util/KmpUtil.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace LogWatch.Util { + internal static class KmpUtil { + private const int BufferSize = 16*1024; + + /// + /// Searches pattern in stream using Knuth-Moris-Pratt algorithm + /// + /// + /// The original code was borrowed from the article http://www.codeproject.com/Articles/34971/A-NET-implementation-for-the-Knuth-Moris-Pratt-KMP by Nairooz Nilafdeen + /// + /// + /// + /// + /// + /// + public static async Task> GetOccurencesAsync( + byte[] pattern, + Stream stream, + int limit, + CancellationToken cancellationToken) { + var transitions = CreatePrefixArray(pattern); + var occurences = new List(Math.Min(4096, limit)); + + var m = 0; + var buffer = new byte[BufferSize]; + + while (true) { + var count = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + + if (count == 0) + break; + + for (var i = 0; i < count; i++) { + cancellationToken.ThrowIfCancellationRequested(); + + if (buffer[i] == pattern[m]) + m++; + else { + var prefix = transitions[m]; + + if (prefix + 1 > pattern.Length && + buffer[i] != pattern[prefix + 1]) + m = 0; + else + m = prefix; + } + + if (m == pattern.Length) { + occurences.Add(stream.Position - count + (i - (pattern.Length - 1))); + + if (occurences.Count == limit) + return occurences; + + m = transitions[m - 1]; + } + } + } + + return occurences; + } + + public static IReadOnlyList GetOccurences( + byte[] pattern, + byte[] buffer, + CancellationToken cancellationToken) { + var transitions = CreatePrefixArray(pattern); + var occurences = new List(); + var m = 0; + + for (var i = 0; i < buffer.Length; i++) { + cancellationToken.ThrowIfCancellationRequested(); + + if (buffer[i] == pattern[m]) + m++; + else { + var prefix = transitions[m]; + + if (prefix + 1 > pattern.Length && + buffer[i] != pattern[prefix + 1]) + m = 0; + else + m = prefix; + } + + if (m == pattern.Length) { + occurences.Add(i - (pattern.Length - 1)); + m = transitions[m - 1]; + } + } + + return occurences; + } + + private static int[] CreatePrefixArray(byte[] pattern) { + var firstByte = pattern[0]; + + var result = new int[pattern.Length]; + + for (var i = 1; i < pattern.Length; i++) { + var aux = new byte[i + 1]; + + Buffer.BlockCopy(pattern, 0, aux, 0, aux.Length); + + result[i] = GetPrefixLegth(aux, firstByte); + } + + return result; + } + + private static int GetPrefixLegth(byte[] array, byte byteToMatch) { + for (var i = 2; i < array.Length; i++) + if (array[i] == byteToMatch) + if (IsSuffixExist(i, array)) + return array.Length - i; + + return 0; + } + + private static bool IsSuffixExist(int index, byte[] array) { + var k = 0; + for (var i = index; i < array.Length; i++) { + if (array[i] != array[k]) + return false; + k++; + } + return true; + } + } +} \ No newline at end of file diff --git a/LogWatch/Util/TextGeometry.cs b/LogWatch/Util/TextGeometry.cs new file mode 100644 index 0000000..c4d398e --- /dev/null +++ b/LogWatch/Util/TextGeometry.cs @@ -0,0 +1,47 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Windows; +using System.Windows.Markup; +using System.Windows.Media; + +namespace LogWatch.Util { + public sealed class TextGeometry : MarkupExtension { + public TextGeometry(string text) { + this.Text = text; + this.FontFamily = new FontFamily("Segoe UI"); + this.FontSize = 12; + this.Brush = Brushes.Black; + } + + public TextGeometry() { + } + + [ConstructorArgument("Text")] + public string Text { get; set; } + + public FontFamily FontFamily { get; set; } + public FontStyle FontStyle { get; set; } + public FontWeight FontWeight { get; set; } + public FontStretch FontStretch { get; set; } + + [TypeConverter(typeof (FontSizeConverter))] + public double FontSize { get; set; } + + public Brush Brush { get; set; } + + public FlowDirection FlowDirection { get; set; } + + public override object ProvideValue(IServiceProvider serviceProvider) { + var text = new FormattedText( + this.Text, + CultureInfo.CurrentCulture, + this.FlowDirection, + new Typeface(this.FontFamily, this.FontStyle, this.FontWeight, this.FontStretch), + this.FontSize, + this.Brush); + + return text.BuildGeometry(new Point(0, 0)); + } + } +} \ No newline at end of file diff --git a/LogWatch/packages.config b/LogWatch/packages.config new file mode 100644 index 0000000..158d8ae --- /dev/null +++ b/LogWatch/packages.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/repositories.config b/packages/repositories.config new file mode 100644 index 0000000..455749c --- /dev/null +++ b/packages/repositories.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file