diff --git a/.editorconfig b/.editorconfig index 7bb69cc..b6b1d97 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,18 +1,20 @@ root = true [*.cs] -dotnet_diagnostic.CS8795.severity = none - -dotnet_diagnostic.CA1062.severity = warning - -dotnet_diagnostic.CA1515.severity = warning - -dotnet_diagnostic.CA1416.severity = warning - -dotnet_diagnostic.CA1812.severity = warning - -dotnet_diagnostic.CA1031.severity = warning - +# These warnings are excluded from TreatWarningsAsErrors in .csproj +# Set to suggestion to show them without failing builds +dotnet_diagnostic.CA1031.severity = suggestion +dotnet_diagnostic.CA1812.severity = suggestion +dotnet_diagnostic.CA2007.severity = suggestion +dotnet_diagnostic.CA1515.severity = suggestion +dotnet_diagnostic.CA1062.severity = suggestion + +[**/Migrations/*.cs] +generated_code = true +dotnet_diagnostic.CA1861.severity = none +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA1812.severity = none +dotnet_diagnostic.CA1062.severity = none [*] charset = utf-8-bom @@ -78,7 +80,7 @@ dotnet_style_prefer_simplified_interpolation = true:suggestion dotnet_style_prefer_simplified_boolean_expressions = true:suggestion # Code quality rules -dotnet_code_quality_unused_parameters = all:warning +dotnet_code_quality_unused_parameters = all:suggestion dotnet_remove_unnecessary_suppression_exclusions = none # ReSharper properties @@ -119,10 +121,14 @@ resharper_web_config_wrong_module_highlighting = warning indent_style = space indent_size = 2 +# YAML files (GitHub Actions) - use LF to match .gitattributes +[*.{yml,yaml}] +indent_style = space +indent_size = 2 +end_of_line = lf + # XML files [*.{appxmanifest,asax,ascx,aspx,axaml,blockshader,c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,cxxm,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,h++,hh,hlsl,hlsli,hlslinc,hp,hpp,hxx,icc,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,mxx,nuspec,paml,razor,resw,resx,shader,shaderFoundry,skin,tcc,tpp,urtshader,usf,ush,uxml,vb,xaml,xamlx,xoml,xsd}] indent_style = space indent_size = 4 -tab_width = 4 - - +tab_width = 4 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e64576e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,47 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Force Windows line endings (CRLF) for C# and related files +*.cs text eol=crlf +*.csproj text eol=crlf +*.sln text eol=crlf +*.props text eol=crlf +*.targets text eol=crlf +*.config text eol=crlf +*.json text eol=crlf +*.xml text eol=crlf +*.xaml text eol=crlf +*.axaml text eol=crlf +*.resx text eol=crlf + +# EditorConfig files +.editorconfig text eol=crlf + +# Shell scripts should use LF +*.sh text eol=lf + +# Batch/PowerShell scripts use CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Markdown files +*.md text eol=crlf + +# YAML files for GitHub Actions +*.yml text eol=lf +*.yaml text eol=lf + +# Binary files +*.dll binary +*.exe binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.7z binary +*.gz binary +*.nupkg binary diff --git a/LogAnalyzerForWindows/App.axaml.cs b/LogAnalyzerForWindows/App.axaml.cs index de87fee..ef33d43 100644 --- a/LogAnalyzerForWindows/App.axaml.cs +++ b/LogAnalyzerForWindows/App.axaml.cs @@ -96,22 +96,27 @@ private static void ConfigureServices(IServiceCollection services) services.AddDbContextFactory(options => { options.UseSqlite(DbContextConfig.ConnectionString); + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); }); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient>(sp => - repository => new PaginationViewModel(repository)); services.AddTransient(); + services.AddTransient(); + + services.AddSingleton>(sp => + repository => new PaginationViewModel(repository)); } private void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e) diff --git a/LogAnalyzerForWindows/Commands/AsyncRelayCommand.cs b/LogAnalyzerForWindows/Commands/AsyncRelayCommand.cs new file mode 100644 index 0000000..90a276e --- /dev/null +++ b/LogAnalyzerForWindows/Commands/AsyncRelayCommand.cs @@ -0,0 +1,83 @@ +using System.Windows.Input; +using Avalonia.Threading; + +namespace LogAnalyzerForWindows.Commands; + +internal sealed class AsyncRelayCommand : ICommand +{ + private readonly Func _execute; + private readonly Func? _canExecute; + private bool _isExecuting; + + public AsyncRelayCommand(Func execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true); + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) return; + + _isExecuting = true; + OnCanExecuteChanged(); + + try + { + await _execute().ConfigureAwait(false); + } + finally + { + _isExecuting = false; + await Dispatcher.UIThread.InvokeAsync(OnCanExecuteChanged); + } + } + + public void OnCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} + +internal sealed class AsyncRelayCommand : ICommand +{ + private readonly Func _execute; + private readonly Func? _canExecute; + private bool _isExecuting; + + public AsyncRelayCommand(Func execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) + { + if (_isExecuting) return false; + if (_canExecute == null) return true; + return _canExecute(parameter is T t ? t : default); + } + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) return; + + _isExecuting = true; + OnCanExecuteChanged(); + + try + { + await _execute(parameter is T t ? t : default).ConfigureAwait(false); + } + finally + { + _isExecuting = false; + await Dispatcher.UIThread.InvokeAsync(OnCanExecuteChanged); + } + } + + public void OnCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/LogAnalyzerForWindows/Database/LogAnalyzerDbContext.cs b/LogAnalyzerForWindows/Database/LogAnalyzerDbContext.cs index c5d2581..7eb99f1 100644 --- a/LogAnalyzerForWindows/Database/LogAnalyzerDbContext.cs +++ b/LogAnalyzerForWindows/Database/LogAnalyzerDbContext.cs @@ -28,6 +28,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); + entity.Property(e => e.Timestamp); entity.Property(e => e.Level).HasMaxLength(50); entity.Property(e => e.Message); @@ -36,11 +37,35 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.CreatedAt); entity.Property(e => e.SessionId).HasMaxLength(100); - entity.HasIndex(e => e.Timestamp); - entity.HasIndex(e => e.Level); - entity.HasIndex(e => e.EventId); - entity.HasIndex(e => e.Source); - entity.HasIndex(e => e.SessionId); + entity.HasIndex(e => new { e.SessionId, e.Timestamp }) + .HasDatabaseName("IX_LogEntries_SessionId_Timestamp"); + + entity.HasIndex(e => new { e.SessionId, e.Level }) + .HasDatabaseName("IX_LogEntries_SessionId_Level"); + + entity.HasIndex(e => new { e.SessionId, e.Source }) + .HasDatabaseName("IX_LogEntries_SessionId_Source"); + + entity.HasIndex(e => new { e.Level, e.Timestamp }) + .HasDatabaseName("IX_LogEntries_Level_Timestamp"); + + entity.HasIndex(e => e.Timestamp) + .HasDatabaseName("IX_LogEntries_Timestamp"); + + entity.HasIndex(e => e.Level) + .HasDatabaseName("IX_LogEntries_Level"); + + entity.HasIndex(e => e.EventId) + .HasDatabaseName("IX_LogEntries_EventId"); + + entity.HasIndex(e => e.Source) + .HasDatabaseName("IX_LogEntries_Source"); + + entity.HasIndex(e => e.SessionId) + .HasDatabaseName("IX_LogEntries_SessionId"); + + entity.HasIndex(e => e.CreatedAt) + .HasDatabaseName("IX_LogEntries_CreatedAt"); }); } } diff --git a/LogAnalyzerForWindows/Database/Repositories/ILogRepository.cs b/LogAnalyzerForWindows/Database/Repositories/ILogRepository.cs index e3d6fbd..8ab8ac4 100644 --- a/LogAnalyzerForWindows/Database/Repositories/ILogRepository.cs +++ b/LogAnalyzerForWindows/Database/Repositories/ILogRepository.cs @@ -50,4 +50,6 @@ Task> GetLogsByLevelAsync( int top = 10, string? sessionId = null, CancellationToken cancellationToken = default); + + Task DeleteSessionAsync(string sessionId); } diff --git a/LogAnalyzerForWindows/Database/Repositories/LogRepository.cs b/LogAnalyzerForWindows/Database/Repositories/LogRepository.cs index 34bb368..b0ffec4 100644 --- a/LogAnalyzerForWindows/Database/Repositories/LogRepository.cs +++ b/LogAnalyzerForWindows/Database/Repositories/LogRepository.cs @@ -50,41 +50,8 @@ public async Task SaveLogsAsync(IEnumerable logs, string sessionI { var query = context.LogEntries.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(levelFilter)) - { - query = query.Where(e => e.Level == levelFilter); - } - - if (startDate.HasValue) - { - query = query.Where(e => e.Timestamp >= startDate.Value); - } - - if (endDate.HasValue) - { - query = query.Where(e => e.Timestamp <= endDate.Value); - } - - if (!string.IsNullOrWhiteSpace(sessionId)) - { - query = query.Where(e => e.SessionId == sessionId); - } - - if (!string.IsNullOrWhiteSpace(searchText)) - { - var searchPattern = $"%{searchText}%"; - query = query.Where(e => e.Message != null && EF.Functions.Like(e.Message, searchPattern)); - } - - if (eventIdFilter.HasValue) - { - query = query.Where(e => e.EventId == eventIdFilter.Value); - } - - if (!string.IsNullOrWhiteSpace(sourceFilter)) - { - query = query.Where(e => e.Source == sourceFilter); - } + query = ApplyFilters(query, levelFilter, startDate, endDate, sessionId, searchText, eventIdFilter, + sourceFilter); var totalCount = await query.CountAsync().ConfigureAwait(false); @@ -181,7 +148,9 @@ public async Task ClearAllLogsAsync() var context = await _contextFactory.CreateDbContextAsync().ConfigureAwait(false); await using (context) { - return await context.Database.ExecuteSqlRawAsync("DELETE FROM LogEntries").ConfigureAwait(false); + return await context.LogEntries + .ExecuteDeleteAsync() + .ConfigureAwait(false); } } @@ -213,7 +182,7 @@ public async Task GetDetailedStatisticsAsync( var context = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var query = context.LogEntries.AsQueryable(); + var query = context.LogEntries.AsNoTracking().AsQueryable(); if (!string.IsNullOrEmpty(sessionId)) query = query.Where(l => l.SessionId == sessionId); @@ -224,51 +193,70 @@ public async Task GetDetailedStatisticsAsync( if (endDate.HasValue) query = query.Where(l => l.Timestamp <= endDate.Value); - var logs = await query.ToListAsync(cancellationToken).ConfigureAwait(false); - - var logsByLevel = logs + var levelCountsTask = query .GroupBy(l => l.Level ?? "Unknown") - .ToDictionary(g => g.Key, g => g.Count()); + .Select(g => new { Level = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Level, x => x.Count, cancellationToken); - var logsBySource = logs - .Where(l => !string.IsNullOrEmpty(l.Source)) + var logsBySourceTask = query + .Where(l => l.Source != null) .GroupBy(l => l.Source!) - .OrderByDescending(g => g.Count()) + .Select(g => new { Source = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) .Take(15) - .ToDictionary(g => g.Key, g => g.Count()); + .ToDictionaryAsync(x => x.Source, x => x.Count, cancellationToken); - var logsByHour = logs - .Where(l => l.Timestamp.HasValue) - .GroupBy(l => new DateTime( - l.Timestamp!.Value.Year, - l.Timestamp.Value.Month, - l.Timestamp.Value.Day, - l.Timestamp.Value.Hour, 0, 0)) - .OrderBy(g => g.Key) - .ToDictionary(g => g.Key, g => g.Count()); - - var logsByDay = logs - .Where(l => l.Timestamp.HasValue) - .GroupBy(l => l.Timestamp!.Value.Date) - .OrderBy(g => g.Key) - .ToDictionary(g => g.Key, g => g.Count()); - - var topEventIds = logs + var topEventIdsTask = query .Where(l => l.EventId.HasValue) .GroupBy(l => l.EventId!.Value) - .OrderByDescending(g => g.Count()) + .Select(g => new { EventId = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) .Take(10) - .ToDictionary(g => g.Key, g => g.Count()); + .ToDictionaryAsync(x => x.EventId, x => x.Count, cancellationToken); + + var logsByHourTask = query + .Where(l => l.Timestamp.HasValue) + .GroupBy(l => new + { l.Timestamp!.Value.Year, l.Timestamp.Value.Month, l.Timestamp.Value.Day, l.Timestamp.Value.Hour }) + .Select(g => new { Date = g.Key, Count = g.Count() }) + .OrderBy(x => x.Date.Year).ThenBy(x => x.Date.Month).ThenBy(x => x.Date.Day).ThenBy(x => x.Date.Hour) + .ToListAsync(cancellationToken); + + var logsByDayTask = query + .Where(l => l.Timestamp.HasValue) + .GroupBy(l => new { l.Timestamp!.Value.Year, l.Timestamp.Value.Month, l.Timestamp.Value.Day }) + .Select(g => new { Date = g.Key, Count = g.Count() }) + .OrderBy(x => x.Date.Year).ThenBy(x => x.Date.Month).ThenBy(x => x.Date.Day) + .ToListAsync(cancellationToken); + + await Task.WhenAll(levelCountsTask, logsBySourceTask, topEventIdsTask, logsByHourTask, logsByDayTask) + .ConfigureAwait(false); + + var levelCounts = await levelCountsTask; + var logsBySource = await logsBySourceTask; + var topEventIds = await topEventIdsTask; + var logsByHourRaw = await logsByHourTask; + var logsByDayRaw = await logsByDayTask; + + var logsByHour = logsByHourRaw.ToDictionary( + x => new DateTime(x.Date.Year, x.Date.Month, x.Date.Day, x.Date.Hour, 0, 0), + x => x.Count); + + var logsByDay = logsByDayRaw.ToDictionary( + x => new DateTime(x.Date.Year, x.Date.Month, x.Date.Day), + x => x.Count); + + var totalCount = levelCounts.Values.Sum(); return new LogStatistics { - TotalLogs = logs.Count, - ErrorCount = logsByLevel.GetValueOrDefault("Error", 0), - WarningCount = logsByLevel.GetValueOrDefault("Warning", 0), - InformationCount = logsByLevel.GetValueOrDefault("Information", 0), - AuditSuccessCount = logsByLevel.GetValueOrDefault("AuditSuccess", 0), - AuditFailureCount = logsByLevel.GetValueOrDefault("AuditFailure", 0), - OtherCount = logsByLevel.GetValueOrDefault("Other", 0) + logsByLevel.GetValueOrDefault("Unknown", 0), + TotalLogs = totalCount, + ErrorCount = levelCounts.GetValueOrDefault("Error", 0), + WarningCount = levelCounts.GetValueOrDefault("Warning", 0), + InformationCount = levelCounts.GetValueOrDefault("Information", 0), + AuditSuccessCount = levelCounts.GetValueOrDefault("AuditSuccess", 0), + AuditFailureCount = levelCounts.GetValueOrDefault("AuditFailure", 0), + OtherCount = levelCounts.GetValueOrDefault("Other", 0) + levelCounts.GetValueOrDefault("Unknown", 0), LogsBySource = logsBySource, LogsByHour = logsByHour, LogsByDay = logsByDay, @@ -287,7 +275,7 @@ public async Task> GetLogsTimeSeriesAsync( var context = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var query = context.LogEntries.AsQueryable(); + var query = context.LogEntries.AsNoTracking().AsQueryable(); if (!string.IsNullOrEmpty(sessionId)) query = query.Where(l => l.SessionId == sessionId); @@ -298,32 +286,52 @@ public async Task> GetLogsTimeSeriesAsync( if (endDate.HasValue) query = query.Where(l => l.Timestamp <= endDate.Value); - var logs = await query - .Where(l => l.Timestamp.HasValue) - .Select(l => l.Timestamp!.Value) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - var interval = groupBy ?? TimeSpan.FromHours(1); - var grouped = logs - .GroupBy(t => new DateTime( - t.Year, t.Month, t.Day, - interval.TotalHours >= 24 ? 0 : t.Hour, - interval.TotalMinutes >= 60 ? 0 : (t.Minute / (int)interval.TotalMinutes) * (int)interval.TotalMinutes, - 0)) - .Select(g => new TimeSeriesPoint + List result; + + if (interval.TotalHours >= 24) + { + var grouped = await query + .Where(l => l.Timestamp.HasValue) + .GroupBy(l => new { l.Timestamp!.Value.Year, l.Timestamp.Value.Month, l.Timestamp.Value.Day }) + .Select(g => new { Date = g.Key, Count = g.Count() }) + .OrderBy(x => x.Date.Year).ThenBy(x => x.Date.Month).ThenBy(x => x.Date.Day) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + result = grouped.Select(g => new TimeSeriesPoint { - Time = g.Key, - Count = g.Count(), - Label = interval.TotalHours >= 24 - ? g.Key.ToString("dd MMM", CultureInfo.InvariantCulture) - : g.Key.ToString("HH:mm", CultureInfo.InvariantCulture) - }) - .OrderBy(p => p.Time) - .ToList(); + Time = new DateTime(g.Date.Year, g.Date.Month, g.Date.Day), + Count = g.Count, + Label = new DateTime(g.Date.Year, g.Date.Month, g.Date.Day).ToString("dd MMM", + CultureInfo.InvariantCulture) + }).ToList(); + } + else + { + var grouped = await query + .Where(l => l.Timestamp.HasValue) + .GroupBy(l => new + { + l.Timestamp!.Value.Year, l.Timestamp.Value.Month, l.Timestamp.Value.Day, l.Timestamp.Value.Hour + }) + .Select(g => new { Date = g.Key, Count = g.Count() }) + .OrderBy(x => x.Date.Year).ThenBy(x => x.Date.Month).ThenBy(x => x.Date.Day) + .ThenBy(x => x.Date.Hour) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + result = grouped.Select(g => new TimeSeriesPoint + { + Time = new DateTime(g.Date.Year, g.Date.Month, g.Date.Day, g.Date.Hour, 0, 0), + Count = g.Count, + Label = new DateTime(g.Date.Year, g.Date.Month, g.Date.Day, g.Date.Hour, 0, 0).ToString("HH:mm", + CultureInfo.InvariantCulture) + }).ToList(); + } - return grouped; + return result; } } @@ -334,7 +342,7 @@ public async Task> GetLogsByLevelAsync( var context = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var query = context.LogEntries.AsQueryable(); + var query = context.LogEntries.AsNoTracking().AsQueryable(); if (!string.IsNullOrEmpty(sessionId)) query = query.Where(l => l.SessionId == sessionId); @@ -355,7 +363,7 @@ public async Task> GetLogsByLevelAsync( var context = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var query = context.LogEntries.AsQueryable(); + var query = context.LogEntries.AsNoTracking().AsQueryable(); if (!string.IsNullOrEmpty(sessionId)) query = query.Where(l => l.SessionId == sessionId); @@ -381,7 +389,7 @@ public async Task> GetLogsByLevelAsync( var context = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - var query = context.LogEntries.AsQueryable(); + var query = context.LogEntries.AsNoTracking().AsQueryable(); if (!string.IsNullOrEmpty(sessionId)) query = query.Where(l => l.SessionId == sessionId); @@ -398,4 +406,56 @@ public async Task> GetLogsByLevelAsync( return result.Select(x => (x.EventId, x.Count)).ToList(); } } + + private static IQueryable ApplyFilters( + IQueryable query, + string? levelFilter, + DateTime? startDate, + DateTime? endDate, + string? sessionId, + string? searchText, + int? eventIdFilter, + string? sourceFilter) + { + if (!string.IsNullOrWhiteSpace(levelFilter)) + query = query.Where(e => e.Level == levelFilter); + + if (startDate.HasValue) + query = query.Where(e => e.Timestamp >= startDate.Value); + + if (endDate.HasValue) + query = query.Where(e => e.Timestamp <= endDate.Value); + + if (!string.IsNullOrWhiteSpace(sessionId)) + query = query.Where(e => e.SessionId == sessionId); + + if (!string.IsNullOrWhiteSpace(searchText)) + { + var searchPattern = $"%{searchText}%"; + query = query.Where(e => e.Message != null && EF.Functions.Like(e.Message, searchPattern)); + } + + if (eventIdFilter.HasValue) + query = query.Where(e => e.EventId == eventIdFilter.Value); + + if (!string.IsNullOrWhiteSpace(sourceFilter)) + query = query.Where(e => e.Source == sourceFilter); + + return query; + } + + public async Task DeleteSessionAsync(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + return 0; + + var context = await _contextFactory.CreateDbContextAsync().ConfigureAwait(false); + await using (context) + { + return await context.LogEntries + .Where(e => e.SessionId == sessionId) + .ExecuteDeleteAsync() + .ConfigureAwait(false); + } + } } diff --git a/LogAnalyzerForWindows/Interfaces/ILogExportService.cs b/LogAnalyzerForWindows/Interfaces/ILogExportService.cs new file mode 100644 index 0000000..095254b --- /dev/null +++ b/LogAnalyzerForWindows/Interfaces/ILogExportService.cs @@ -0,0 +1,14 @@ +using LogAnalyzerForWindows.Models; + +namespace LogAnalyzerForWindows.Interfaces; + +internal interface ILogExportService +{ + Task ExportLogsAsync( + IEnumerable logs, + string format, + string? fileName = null, + CancellationToken cancellationToken = default); + + string GetSupportedFormatsDescription(); +} diff --git a/LogAnalyzerForWindows/Interfaces/ILogManager.cs b/LogAnalyzerForWindows/Interfaces/ILogManager.cs deleted file mode 100644 index c25965c..0000000 --- a/LogAnalyzerForWindows/Interfaces/ILogManager.cs +++ /dev/null @@ -1,38 +0,0 @@ -using LogAnalyzerForWindows.Models; - -namespace LogAnalyzerForWindows.Interfaces; - -/// -/// Defines the contract for managing and processing log entries. -/// -/// -/// This interface coordinates the log processing pipeline by orchestrating -/// the analyzer, formatter, and writer components to transform raw log entries -/// into formatted output. -/// -internal interface ILogManager -{ - /// - /// Processes a collection of log entries through the analysis, formatting, and writing pipeline. - /// - /// The collection of log entries to process. - /// - /// A cancellation token to observe while processing logs. The default value is . - /// - /// A task representing the asynchronous processing operation. - /// - /// This method: - /// - /// Analyzes logs using the configured analyzer - /// Formats each log entry using the configured formatter - /// Writes formatted logs using the configured writer - /// Processes logs in batches to optimize performance - /// Supports cancellation via the provided token - /// - /// - /// Thrown when log analysis fails due to invalid data. - /// Thrown when the log processing pipeline encounters an invalid state. - /// Thrown when writing logs fails due to I/O errors. - /// Thrown when the operation is cancelled via the cancellation token. - Task ProcessLogsAsync(IEnumerable logs, CancellationToken cancellationToken = default); -} diff --git a/LogAnalyzerForWindows/Interfaces/ILogStatisticsService.cs b/LogAnalyzerForWindows/Interfaces/ILogStatisticsService.cs new file mode 100644 index 0000000..4398688 --- /dev/null +++ b/LogAnalyzerForWindows/Interfaces/ILogStatisticsService.cs @@ -0,0 +1,13 @@ +using LogAnalyzerForWindows.Models; + +namespace LogAnalyzerForWindows.Interfaces; + +internal interface ILogStatisticsService +{ + Task GetStatisticsAsync(string? sessionId, CancellationToken ct = default); + Task> GetTimeSeriesAsync(string? sessionId, TimeSpan? groupBy, CancellationToken ct = default); + Task> GetTopSourcesAsync(int top, string? sessionId, CancellationToken ct = default); + Task> GetTopEventIdsAsync(int top, string? sessionId, CancellationToken ct = default); + Task> GetSessionsAsync(CancellationToken ct = default); + void InvalidateCache(string? sessionId = null); +} diff --git a/LogAnalyzerForWindows/Interfaces/ISettingsService.cs b/LogAnalyzerForWindows/Interfaces/ISettingsService.cs index 906b506..bd0969b 100644 --- a/LogAnalyzerForWindows/Interfaces/ISettingsService.cs +++ b/LogAnalyzerForWindows/Interfaces/ISettingsService.cs @@ -9,4 +9,5 @@ internal interface ISettingsService SmtpSettings GetSmtpSettings(); GeneralSettings GetGeneralSettings(); bool IsSmtpConfigured(); + bool IsAutoStartEnabled(); } diff --git a/LogAnalyzerForWindows/LogAnalyzerForWindows.csproj b/LogAnalyzerForWindows/LogAnalyzerForWindows.csproj index f2f478d..b309fd9 100644 --- a/LogAnalyzerForWindows/LogAnalyzerForWindows.csproj +++ b/LogAnalyzerForWindows/LogAnalyzerForWindows.csproj @@ -25,9 +25,9 @@ LogAnalyzerForWindows - 1.4.1.0 - 1.4.1.0 - 1.4.1 + 1.5.0.0 + 1.5.0.0 + 1.5.0 Bohdan Harabadzhyu @@ -70,7 +70,7 @@ true - CA1031;CA1062;CA1416;CA1515;CA1812 + CA1031;CA1812;CA2007;CA1515;CA1062 latest @@ -131,8 +131,4 @@ - - - $(NoWarn);CS8795 - diff --git a/LogAnalyzerForWindows/Migrations/20251216114637_InitialCreate.Designer.cs b/LogAnalyzerForWindows/Migrations/20260203190033_Initial.Designer.cs similarity index 59% rename from LogAnalyzerForWindows/Migrations/20251216114637_InitialCreate.Designer.cs rename to LogAnalyzerForWindows/Migrations/20260203190033_Initial.Designer.cs index 19f9d62..093b6bf 100644 --- a/LogAnalyzerForWindows/Migrations/20251216114637_InitialCreate.Designer.cs +++ b/LogAnalyzerForWindows/Migrations/20260203190033_Initial.Designer.cs @@ -11,8 +11,8 @@ namespace LogAnalyzerForWindows.Migrations { [DbContext(typeof(LogAnalyzerDbContext))] - [Migration("20251216114637_InitialCreate")] - partial class InitialCreate + [Migration("20260203190033_Initial")] + partial class Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -52,15 +52,35 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("EventId"); + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_LogEntries_CreatedAt"); - b.HasIndex("Level"); + b.HasIndex("EventId") + .HasDatabaseName("IX_LogEntries_EventId"); - b.HasIndex("SessionId"); + b.HasIndex("Level") + .HasDatabaseName("IX_LogEntries_Level"); - b.HasIndex("Source"); + b.HasIndex("SessionId") + .HasDatabaseName("IX_LogEntries_SessionId"); - b.HasIndex("Timestamp"); + b.HasIndex("Source") + .HasDatabaseName("IX_LogEntries_Source"); + + b.HasIndex("Timestamp") + .HasDatabaseName("IX_LogEntries_Timestamp"); + + b.HasIndex("Level", "Timestamp") + .HasDatabaseName("IX_LogEntries_Level_Timestamp"); + + b.HasIndex("SessionId", "Level") + .HasDatabaseName("IX_LogEntries_SessionId_Level"); + + b.HasIndex("SessionId", "Source") + .HasDatabaseName("IX_LogEntries_SessionId_Source"); + + b.HasIndex("SessionId", "Timestamp") + .HasDatabaseName("IX_LogEntries_SessionId_Timestamp"); b.ToTable("LogEntries"); }); diff --git a/LogAnalyzerForWindows/Migrations/20251216114637_InitialCreate.cs b/LogAnalyzerForWindows/Migrations/20260203190033_Initial.cs similarity index 70% rename from LogAnalyzerForWindows/Migrations/20251216114637_InitialCreate.cs rename to LogAnalyzerForWindows/Migrations/20260203190033_Initial.cs index e955d32..e9589f9 100644 --- a/LogAnalyzerForWindows/Migrations/20251216114637_InitialCreate.cs +++ b/LogAnalyzerForWindows/Migrations/20260203190033_Initial.cs @@ -6,7 +6,7 @@ namespace LogAnalyzerForWindows.Migrations { /// - public partial class InitialCreate : Migration + public partial class Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -30,6 +30,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_LogEntries", x => x.Id); }); + migrationBuilder.CreateIndex( + name: "IX_LogEntries_CreatedAt", + table: "LogEntries", + column: "CreatedAt"); + migrationBuilder.CreateIndex( name: "IX_LogEntries_EventId", table: "LogEntries", @@ -40,11 +45,31 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "LogEntries", column: "Level"); + migrationBuilder.CreateIndex( + name: "IX_LogEntries_Level_Timestamp", + table: "LogEntries", + columns: new[] { "Level", "Timestamp" }); + migrationBuilder.CreateIndex( name: "IX_LogEntries_SessionId", table: "LogEntries", column: "SessionId"); + migrationBuilder.CreateIndex( + name: "IX_LogEntries_SessionId_Level", + table: "LogEntries", + columns: new[] { "SessionId", "Level" }); + + migrationBuilder.CreateIndex( + name: "IX_LogEntries_SessionId_Source", + table: "LogEntries", + columns: new[] { "SessionId", "Source" }); + + migrationBuilder.CreateIndex( + name: "IX_LogEntries_SessionId_Timestamp", + table: "LogEntries", + columns: new[] { "SessionId", "Timestamp" }); + migrationBuilder.CreateIndex( name: "IX_LogEntries_Source", table: "LogEntries", diff --git a/LogAnalyzerForWindows/Migrations/LogAnalyzerDbContextModelSnapshot.cs b/LogAnalyzerForWindows/Migrations/LogAnalyzerDbContextModelSnapshot.cs index 9fea0ac..beb9df8 100644 --- a/LogAnalyzerForWindows/Migrations/LogAnalyzerDbContextModelSnapshot.cs +++ b/LogAnalyzerForWindows/Migrations/LogAnalyzerDbContextModelSnapshot.cs @@ -49,15 +49,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("EventId"); + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_LogEntries_CreatedAt"); - b.HasIndex("Level"); + b.HasIndex("EventId") + .HasDatabaseName("IX_LogEntries_EventId"); - b.HasIndex("SessionId"); + b.HasIndex("Level") + .HasDatabaseName("IX_LogEntries_Level"); - b.HasIndex("Source"); + b.HasIndex("SessionId") + .HasDatabaseName("IX_LogEntries_SessionId"); - b.HasIndex("Timestamp"); + b.HasIndex("Source") + .HasDatabaseName("IX_LogEntries_Source"); + + b.HasIndex("Timestamp") + .HasDatabaseName("IX_LogEntries_Timestamp"); + + b.HasIndex("Level", "Timestamp") + .HasDatabaseName("IX_LogEntries_Level_Timestamp"); + + b.HasIndex("SessionId", "Level") + .HasDatabaseName("IX_LogEntries_SessionId_Level"); + + b.HasIndex("SessionId", "Source") + .HasDatabaseName("IX_LogEntries_SessionId_Source"); + + b.HasIndex("SessionId", "Timestamp") + .HasDatabaseName("IX_LogEntries_SessionId_Timestamp"); b.ToTable("LogEntries"); }); diff --git a/LogAnalyzerForWindows/Models/LogManager.cs b/LogAnalyzerForWindows/Models/LogManager.cs deleted file mode 100644 index 2ccd351..0000000 --- a/LogAnalyzerForWindows/Models/LogManager.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Threading.Channels; -using LogAnalyzerForWindows.Formatter.Interfaces; -using LogAnalyzerForWindows.Interfaces; -using LogAnalyzerForWindows.Models.Analyzer; -using LogAnalyzerForWindows.Models.Reader.Interfaces; -using LogAnalyzerForWindows.Models.Writer.Interfaces; - -namespace LogAnalyzerForWindows.Models; - -internal sealed class LogManager : ILogManager -{ - private readonly ILogReader _reader; - private readonly LogAnalyzer _analyzer; - private readonly ILogFormatter _formatter; - private readonly ILogWriter _writer; - private const int ChannelCapacity = 1000; - - public LogManager(ILogReader reader, LogAnalyzer analyzer, ILogFormatter formatter, ILogWriter writer) - { - _reader = reader ?? throw new ArgumentNullException(nameof(reader)); - _analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer)); - _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); - _writer = writer ?? throw new ArgumentNullException(nameof(writer)); - } - - public async Task ProcessLogsAsync(IEnumerable logs, CancellationToken cancellationToken = default) - { - if (logs == null) return; - - var logsList = logs as IReadOnlyList ?? logs.ToList(); - - if (logsList.Count == 0) return; - - try - { - _analyzer.Analyze(logsList); - } - catch (ArgumentException ex) - { - Console.WriteLine($"An error occurred while analyzing logs: {ex.Message}"); - return; - } - catch (InvalidOperationException ex) - { - Console.WriteLine($"An error occurred while analyzing logs: {ex.Message}"); - return; - } - - var channel = Channel.CreateBounded(new BoundedChannelOptions(ChannelCapacity) - { - FullMode = BoundedChannelFullMode.Wait, - SingleReader = false, - SingleWriter = true - }); - - var consumerCount = Math.Min(Environment.ProcessorCount, 4); - var consumers = Enumerable.Range(0, consumerCount) - .Select(_ => ConsumeLogsAsync(channel.Reader, cancellationToken)) - .ToArray(); - - try - { - foreach (var log in logsList) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (log != null) - { - await channel.Writer.WriteAsync(log, cancellationToken).ConfigureAwait(false); - } - } - } - finally - { - channel.Writer.Complete(); - } - - await Task.WhenAll(consumers).ConfigureAwait(false); - } - - private async Task ConsumeLogsAsync(ChannelReader reader, CancellationToken cancellationToken) - { - await foreach (var log in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) - { - try - { - var formattedLog = _formatter.Format(log); - _writer.Write(formattedLog); - } - catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) - { - Console.WriteLine($"An error occurred while formatting or writing log: {ex.Message}"); - } - } - } -} diff --git a/LogAnalyzerForWindows/Models/LogMonitor.cs b/LogAnalyzerForWindows/Models/LogMonitor.cs index ac1907b..626e905 100644 --- a/LogAnalyzerForWindows/Models/LogMonitor.cs +++ b/LogAnalyzerForWindows/Models/LogMonitor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Threading.Channels; using LogAnalyzerForWindows.Interfaces; using LogAnalyzerForWindows.Models.Reader.Interfaces; @@ -6,13 +7,14 @@ namespace LogAnalyzerForWindows.Models; internal sealed class LogMonitor : ILogMonitor, IDisposable { - private HashSet _lastProcessedLogs = []; + private Channel>? _logChannel; private CancellationTokenSource? _cts; private volatile bool _isMonitoring; private bool _disposedValue; private const int PollingIntervalMs = 1000; private const int ErrorRetryDelayMs = 5000; + private const int ChannelCapacity = 100; public bool IsMonitoring => _isMonitoring; @@ -26,52 +28,108 @@ public void Monitor(ILogReader reader) if (_isMonitoring) return; - _lastProcessedLogs = []; + _logChannel = Channel.CreateBounded>( + new BoundedChannelOptions(ChannelCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = true + }); + + _cts?.Dispose(); _cts = new CancellationTokenSource(); _isMonitoring = true; MonitoringStarted?.Invoke(this, EventArgs.Empty); - _ = MonitorAsync(reader, _cts.Token); + _ = Task.Run(() => ProduceLogsAsync(reader, _cts.Token)); + _ = Task.Run(() => ConsumeLogsAsync(_cts.Token)); } - private async Task MonitorAsync(ILogReader reader, CancellationToken cancellationToken) + private async Task ProduceLogsAsync(ILogReader reader, CancellationToken cancellationToken) { + if (_logChannel == null) return; + try { while (!cancellationToken.IsCancellationRequested) { try { - var currentLogs = await reader.ReadLogsAsync(cancellationToken).ConfigureAwait(false); + var logs = await reader.ReadLogsAsync(cancellationToken).ConfigureAwait(false); - if (currentLogs.Count > 0) + if (logs.Count > 0) + { + await _logChannel.Writer.WriteAsync(logs, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Debug.WriteLine($"Error reading logs: {ex.GetType().Name}: {ex.Message}"); + try { - var logsToProcess = currentLogs.ToList(); - _ = Task.Run(() => LogsChanged?.Invoke(this, new LogsChangedEventArgs(logsToProcess)), cancellationToken); - _lastProcessedLogs = new HashSet(currentLogs); + await Task.Delay(ErrorRetryDelayMs, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; } } - catch (IOException ex) + + try { - Debug.WriteLine($"Error reading logs: {ex.Message}"); - await Task.Delay(ErrorRetryDelayMs, cancellationToken).ConfigureAwait(false); - continue; + await Task.Delay(PollingIntervalMs, cancellationToken).ConfigureAwait(false); } - catch (UnauthorizedAccessException ex) + catch (OperationCanceledException) { - Debug.WriteLine($"Error reading logs: {ex.Message}"); - await Task.Delay(ErrorRetryDelayMs, cancellationToken).ConfigureAwait(false); - continue; + break; } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Critical error in producer: {ex.GetType().Name}: {ex.Message}"); + } + finally + { + _logChannel?.Writer.TryComplete(); + } + } + + private async Task ConsumeLogsAsync(CancellationToken cancellationToken) + { + if (_logChannel == null) return; - await Task.Delay(PollingIntervalMs, cancellationToken).ConfigureAwait(false); + try + { + await foreach (var logs in _logChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + LogsChanged?.Invoke(this, new LogsChangedEventArgs(logs)); + } + catch (Exception ex) + { + Debug.WriteLine($"Error in LogsChanged handler: {ex.GetType().Name}: {ex.Message}"); + } } } catch (OperationCanceledException) { // Normal cancellation } + catch (ChannelClosedException) + { + // Channel was closed + } + catch (Exception ex) + { + Debug.WriteLine($"Critical error in consumer: {ex.GetType().Name}: {ex.Message}"); + } finally { _isMonitoring = false; @@ -96,6 +154,7 @@ private void Dispose(bool disposing) StopMonitoring(); _cts?.Dispose(); } + _disposedValue = true; } } diff --git a/LogAnalyzerForWindows/Models/Reader/WindowsEventLogReader.cs b/LogAnalyzerForWindows/Models/Reader/WindowsEventLogReader.cs index 125c9be..bc6f9cd 100644 --- a/LogAnalyzerForWindows/Models/Reader/WindowsEventLogReader.cs +++ b/LogAnalyzerForWindows/Models/Reader/WindowsEventLogReader.cs @@ -433,7 +433,7 @@ private List ReadLogsInternal(CancellationToken cancellationToken) System.Diagnostics.Debug.WriteLine($"Access denied for log '{_logName}': {ex.Message}"); } - return logs.ToList(); + return logs.OrderBy(l => l.Timestamp).ToList(); } private static DateTime? ParseTimestamp(object? timeGeneratedValue) diff --git a/LogAnalyzerForWindows/Models/Writer/Interfaces/ILogWriter.cs b/LogAnalyzerForWindows/Models/Writer/Interfaces/ILogWriter.cs deleted file mode 100644 index ae600ed..0000000 --- a/LogAnalyzerForWindows/Models/Writer/Interfaces/ILogWriter.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace LogAnalyzerForWindows.Models.Writer.Interfaces; - -internal interface ILogWriter -{ - void Write(LogEntry log); -} diff --git a/LogAnalyzerForWindows/Models/Writer/TextBoxLogWriter.cs b/LogAnalyzerForWindows/Models/Writer/TextBoxLogWriter.cs deleted file mode 100644 index cccc30c..0000000 --- a/LogAnalyzerForWindows/Models/Writer/TextBoxLogWriter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using LogAnalyzerForWindows.Formatter.Interfaces; -using LogAnalyzerForWindows.Models.Writer.Interfaces; - -namespace LogAnalyzerForWindows.Models.Writer; - -internal sealed class TextBoxLogWriter : ILogWriter -{ - private readonly ILogFormatter _formatter; - private readonly Action _updateAction; - - public TextBoxLogWriter(ILogFormatter formatter, Action updateAction) - { - _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); - _updateAction = updateAction ?? throw new ArgumentNullException(nameof(updateAction)); - } - - public void Write(LogEntry log) - { - if (log == null) return; - - string formattedLogString = _formatter.Format(log).ToString(); - _updateAction(formattedLogString); - } -} diff --git a/LogAnalyzerForWindows/Services/LogExportService.cs b/LogAnalyzerForWindows/Services/LogExportService.cs new file mode 100644 index 0000000..040ed41 --- /dev/null +++ b/LogAnalyzerForWindows/Services/LogExportService.cs @@ -0,0 +1,105 @@ +using LogAnalyzerForWindows.Formatter; +using LogAnalyzerForWindows.Formatter.Interfaces; +using LogAnalyzerForWindows.Interfaces; +using LogAnalyzerForWindows.Models; + +namespace LogAnalyzerForWindows.Services; + +internal sealed class LogExportService : ILogExportService +{ + private static readonly Dictionary> FormatterFactories = + new(StringComparer.OrdinalIgnoreCase) + { + ["TXT"] = () => new LogFormatter(), + ["JSON"] = () => new JsonLogFormatter() + }; + + public async Task ExportLogsAsync( + IEnumerable logs, + string format, + string? fileName = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(logs); + ArgumentException.ThrowIfNullOrWhiteSpace(format); + + var logsList = logs as IReadOnlyList ?? logs.ToList(); + if (logsList.Count == 0) + { + throw new InvalidOperationException("No logs to export."); + } + + var normalizedFormat = format.ToUpperInvariant(); + if (!FormatterFactories.TryGetValue(normalizedFormat, out var formatterFactory)) + { + throw new InvalidOperationException( + $"Unknown format: {format}. Supported formats: {GetSupportedFormatsDescription()}"); + } + + return await Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + var formatter = formatterFactory(); + var sortedLogs = logsList.OrderBy(log => log.Timestamp); + var formattedLines = sortedLogs.Select(log => + { + var formattedResult = formatter.Format(log); + return formattedResult.ToString() ?? string.Empty; + }); + + var content = string.Join(Environment.NewLine, formattedLines); + + var filePath = GetExportFilePath(normalizedFormat, fileName); + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(filePath, content); + return filePath; + }, cancellationToken); + } + + public string GetSupportedFormatsDescription() + { + return string.Join(", ", FormatterFactories.Keys); + } + + private static string GetExportFilePath(string format, string? customFileName) + { + var basePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "AzioEventLogAnalyzer"); + + var dateFolder = DateTime.Now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + var targetFolder = Path.Combine(basePath, dateFolder); + + if (!Directory.Exists(targetFolder)) + { + Directory.CreateDirectory(targetFolder); + } + + string fileName; + if (string.IsNullOrEmpty(customFileName)) + { + var timestamp = DateTime.Now.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); + fileName = $"logs_{timestamp}.{format.ToUpperInvariant()}"; + } + else + { + var safeFileName = string.Join("_", customFileName.Split(Path.GetInvalidFileNameChars())); + + if (!safeFileName.EndsWith($".{format}", StringComparison.OrdinalIgnoreCase)) + { + safeFileName = $"{safeFileName}.{format.ToUpperInvariant()}"; + } + + fileName = safeFileName; + } + + return Path.Combine(targetFolder, fileName); + } +} diff --git a/LogAnalyzerForWindows/Services/LogStatisticsService.cs b/LogAnalyzerForWindows/Services/LogStatisticsService.cs new file mode 100644 index 0000000..9e21c88 --- /dev/null +++ b/LogAnalyzerForWindows/Services/LogStatisticsService.cs @@ -0,0 +1,168 @@ +using System.Collections.Concurrent; +using LogAnalyzerForWindows.Database.Repositories; +using LogAnalyzerForWindows.Interfaces; +using LogAnalyzerForWindows.Models; + +namespace LogAnalyzerForWindows.Services; + +internal sealed class LogStatisticsService : ILogStatisticsService +{ + private readonly ILogRepository _repository; + private readonly ConcurrentDictionary> _statsCache = new(); + private readonly ConcurrentDictionary>> _timeSeriesCache = new(); + private readonly ConcurrentDictionary>> _sourcesCache = new(); + private readonly ConcurrentDictionary>> _eventIdsCache = new(); + private readonly ConcurrentDictionary>> _sessionsCache = new(); + + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(1); + + public LogStatisticsService(ILogRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async Task GetStatisticsAsync(string? sessionId, CancellationToken ct = default) + { + var cacheKey = $"stats_{sessionId ?? "all"}"; + + if (TryGetFromCache(_statsCache, cacheKey, out var cached)) + return cached!; + + var stats = await _repository.GetDetailedStatisticsAsync(sessionId, cancellationToken: ct) + .ConfigureAwait(false); + + AddToCache(_statsCache, cacheKey, stats); + + return stats; + } + + public async Task> GetTimeSeriesAsync(string? sessionId, TimeSpan? groupBy, + CancellationToken ct = default) + { + var cacheKey = $"timeseries_{sessionId ?? "all"}_{groupBy?.TotalMinutes ?? 60}"; + + if (TryGetFromCache(_timeSeriesCache, cacheKey, out var cached)) + return cached!; + + var timeSeries = await _repository.GetLogsTimeSeriesAsync(sessionId, groupBy: groupBy, cancellationToken: ct) + .ConfigureAwait(false); + + AddToCache(_timeSeriesCache, cacheKey, timeSeries); + + return timeSeries; + } + + public async Task> GetTopSourcesAsync(int top, string? sessionId, + CancellationToken ct = default) + { + var cacheKey = $"sources_{sessionId ?? "all"}_{top}"; + + if (TryGetFromCache(_sourcesCache, cacheKey, out var cached)) + return cached!; + + var sources = await _repository.GetTopSourcesAsync(top, sessionId, ct).ConfigureAwait(false); + + AddToCache(_sourcesCache, cacheKey, sources); + + return sources; + } + + public async Task> GetTopEventIdsAsync(int top, string? sessionId, + CancellationToken ct = default) + { + var cacheKey = $"eventids_{sessionId ?? "all"}_{top}"; + + if (TryGetFromCache(_eventIdsCache, cacheKey, out var cached)) + return cached!; + + var eventIds = await _repository.GetTopEventIdsAsync(top, sessionId, ct).ConfigureAwait(false); + + AddToCache(_eventIdsCache, cacheKey, eventIds); + + return eventIds; + } + + public async Task> GetSessionsAsync(CancellationToken ct = default) + { + const string cacheKey = "sessions"; + + if (TryGetFromCache(_sessionsCache, cacheKey, out var cached)) + return cached!; + + var sessions = await _repository.GetSessionIdsAsync().ConfigureAwait(false); + + AddToCache(_sessionsCache, cacheKey, sessions); + + return sessions; + } + + public void InvalidateCache(string? sessionId = null) + { + if (sessionId == null) + { + _statsCache.Clear(); + _timeSeriesCache.Clear(); + _sourcesCache.Clear(); + _eventIdsCache.Clear(); + _sessionsCache.Clear(); + } + else + { + var keysToRemove = _statsCache.Keys + .Where(k => k.Contains(sessionId, StringComparison.Ordinal)) + .ToList(); + foreach (var key in keysToRemove) + _statsCache.TryRemove(key, out _); + + keysToRemove = _timeSeriesCache.Keys + .Where(k => k.Contains(sessionId, StringComparison.Ordinal)) + .ToList(); + foreach (var key in keysToRemove) + _timeSeriesCache.TryRemove(key, out _); + + keysToRemove = _sourcesCache.Keys + .Where(k => k.Contains(sessionId, StringComparison.Ordinal)) + .ToList(); + foreach (var key in keysToRemove) + _sourcesCache.TryRemove(key, out _); + + keysToRemove = _eventIdsCache.Keys + .Where(k => k.Contains(sessionId, StringComparison.Ordinal)) + .ToList(); + foreach (var key in keysToRemove) + _eventIdsCache.TryRemove(key, out _); + + _sessionsCache.Clear(); + } + } + + private static bool TryGetFromCache(ConcurrentDictionary> cache, string key, out T? value) + { + if (cache.TryGetValue(key, out var entry) && !entry.IsExpired) + { + value = entry.Value; + return true; + } + + value = default; + return false; + } + + private static void AddToCache(ConcurrentDictionary> cache, string key, T value) + { + cache[key] = new CacheEntry(value, DateTime.UtcNow.Add(CacheDuration)); + } + + private sealed class CacheEntry + { + public T Value { get; } + public DateTime ExpiresAt { get; } + public bool IsExpired => DateTime.UtcNow >= ExpiresAt; + + public CacheEntry(T value, DateTime expiresAt) + { + Value = value; + ExpiresAt = expiresAt; + } + } +} diff --git a/LogAnalyzerForWindows/Services/SettingsService.cs b/LogAnalyzerForWindows/Services/SettingsService.cs index 1c5fc91..3917971 100644 --- a/LogAnalyzerForWindows/Services/SettingsService.cs +++ b/LogAnalyzerForWindows/Services/SettingsService.cs @@ -4,17 +4,15 @@ using System.Text.Json; using LogAnalyzerForWindows.Interfaces; using LogAnalyzerForWindows.Models; -using Microsoft.Win32; namespace LogAnalyzerForWindows.Services; internal sealed class SettingsService : ISettingsService { private const string SettingsFileName = "settings.json"; - private const string AutoStartRegistryKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; - private const string AppName = "AzioEventLogAnalyzer"; + private const string TaskName = "AzioEventLogAnalyzer"; - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; @@ -60,12 +58,11 @@ public async Task SaveSettingsAsync(AppSettings settings) }; var json = JsonSerializer.Serialize(settingsToSave, JsonOptions); - await File.WriteAllTextAsync(_settingsFilePath, json).ConfigureAwait(false); _cachedSettings = settings; - UpdateAutoStartRegistry(settings.General.AutoStartWithWindows); + await UpdateAutoStartTaskAsync(settings.General.AutoStartWithWindows).ConfigureAwait(false); } catch (IOException ex) { @@ -86,6 +83,11 @@ public bool IsSmtpConfigured() !string.IsNullOrWhiteSpace(_cachedSettings.Smtp.Password); } + public bool IsAutoStartEnabled() + { + return CheckScheduledTaskExists(); + } + private void LoadSettings() { try @@ -93,6 +95,7 @@ private void LoadSettings() if (!File.Exists(_settingsFilePath)) { _cachedSettings = new AppSettings(); + SyncAutoStartSetting(); return; } @@ -103,6 +106,7 @@ private void LoadSettings() { loadedSettings.Smtp.Password = DecryptPassword(loadedSettings.Smtp.Password); _cachedSettings = loadedSettings; + SyncAutoStartSetting(); } } catch (JsonException ex) @@ -117,6 +121,130 @@ private void LoadSettings() } } + private void SyncAutoStartSetting() + { + _cachedSettings.General.AutoStartWithWindows = CheckScheduledTaskExists(); + } + + private static bool CheckScheduledTaskExists() + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "schtasks.exe", + Arguments = $"/Query /TN \"{TaskName}\" /FO LIST", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process == null) return false; + + process.WaitForExit(5000); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Debug.WriteLine($"Error checking scheduled task: {ex.Message}"); + return false; + } + } + + private static async Task UpdateAutoStartTaskAsync(bool enable) + { + try + { + if (enable) + { + await CreateScheduledTaskAsync().ConfigureAwait(false); + } + else + { + await DeleteScheduledTaskAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error updating auto-start task: {ex.Message}"); + throw new InvalidOperationException($"Failed to update auto-start: {ex.Message}", ex); + } + } + + private static async Task CreateScheduledTaskAsync() + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + throw new InvalidOperationException("Cannot determine executable path"); + } + + await DeleteScheduledTaskAsync().ConfigureAwait(false); + + var arguments = $"/Create /TN \"{TaskName}\" " + + $"/TR \"\\\"{exePath}\\\"\" " + + "/SC ONLOGON " + + "/RL HIGHEST " + + "/F " + + "/DELAY 0000:30"; + + await RunSchtasksAsync(arguments).ConfigureAwait(false); + Debug.WriteLine($"Scheduled task '{TaskName}' created successfully"); + } + + private static async Task DeleteScheduledTaskAsync() + { + var arguments = $"/Delete /TN \"{TaskName}\" /F"; + + try + { + await RunSchtasksAsync(arguments).ConfigureAwait(false); + Debug.WriteLine($"Scheduled task '{TaskName}' deleted"); + } + catch + { + } + } + + private static async Task RunSchtasksAsync(string arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = "schtasks.exe", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process == null) + { + throw new InvalidOperationException("Failed to start schtasks.exe"); + } + + var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + var error = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + + await process.WaitForExitAsync().ConfigureAwait(false); + + if (process.ExitCode != 0) + { + Debug.WriteLine($"schtasks output: {output}"); + Debug.WriteLine($"schtasks error: {error}"); + + if (process.ExitCode == 1 && arguments.Contains("/Delete", StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException($"schtasks.exe failed with exit code {process.ExitCode}: {error}"); + } + } + private string EncryptPassword(string password) { if (string.IsNullOrEmpty(password)) @@ -125,10 +253,10 @@ private string EncryptPassword(string password) try { var passwordBytes = Encoding.UTF8.GetBytes(password); - var encryptedBytes = ProtectedData.Protect( + var encryptedBytes = System.Security.Cryptography.ProtectedData.Protect( passwordBytes, _encryptionKey, - DataProtectionScope.CurrentUser); + System.Security.Cryptography.DataProtectionScope.CurrentUser); return Convert.ToBase64String(encryptedBytes); } catch (CryptographicException ex) @@ -146,10 +274,10 @@ private string DecryptPassword(string encryptedPassword) try { var encryptedBytes = Convert.FromBase64String(encryptedPassword); - var decryptedBytes = ProtectedData.Unprotect( + var decryptedBytes = System.Security.Cryptography.ProtectedData.Unprotect( encryptedBytes, _encryptionKey, - DataProtectionScope.CurrentUser); + System.Security.Cryptography.DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(decryptedBytes); } catch (FormatException ex) @@ -163,34 +291,4 @@ private string DecryptPassword(string encryptedPassword) return string.Empty; } } - - private static void UpdateAutoStartRegistry(bool enable) - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(AutoStartRegistryKey, writable: true); - if (key is null) return; - - if (enable) - { - var exePath = Environment.ProcessPath; - if (!string.IsNullOrEmpty(exePath)) - { - key.SetValue(AppName, $"\"{exePath}\""); - } - } - else - { - key.DeleteValue(AppName, throwOnMissingValue: false); - } - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Cannot modify registry: {ex.Message}"); - } - catch (IOException ex) - { - Debug.WriteLine($"Registry IO error: {ex.Message}"); - } - } } diff --git a/LogAnalyzerForWindows/ViewModels/DashboardViewModel.cs b/LogAnalyzerForWindows/ViewModels/DashboardViewModel.cs index 417dad7..1bc199f 100644 --- a/LogAnalyzerForWindows/ViewModels/DashboardViewModel.cs +++ b/LogAnalyzerForWindows/ViewModels/DashboardViewModel.cs @@ -1,7 +1,5 @@ -using System.ComponentModel; -using System.Diagnostics; +using System.Diagnostics; using System.Globalization; -using System.Runtime.CompilerServices; using System.Windows.Input; using Avalonia.Collections; using Avalonia.Threading; @@ -10,19 +8,21 @@ using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LogAnalyzerForWindows.Commands; -using LogAnalyzerForWindows.Database.Repositories; +using LogAnalyzerForWindows.Interfaces; using LogAnalyzerForWindows.Models; using SkiaSharp; namespace LogAnalyzerForWindows.ViewModels; -internal sealed class DashboardViewModel : INotifyPropertyChanged +internal sealed class DashboardViewModel : ViewModelBase, IAsyncDisposable { - private readonly ILogRepository _repository; + private readonly ILogStatisticsService _statisticsService; + private CancellationTokenSource? _loadingCts; private bool _isLoading; private string? _selectedSession; private LogStatistics? _statistics; private string _statusMessage = "Ready"; + private bool _disposedValue; private ISeries[] _levelPieSeries = []; private ISeries[] _timelineSeries = []; @@ -33,23 +33,20 @@ internal sealed class DashboardViewModel : INotifyPropertyChanged private Axis[] _timelineYAxes = []; private Axis[] _sourcesXAxes = []; private Axis[] _sourcesYAxes = []; - private Axis[] _eventIdsXAxes = []; private Axis[] _eventIdsYAxes = []; - public DashboardViewModel(ILogRepository repository) + public DashboardViewModel(ILogStatisticsService statisticsService) { - _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _statisticsService = statisticsService ?? throw new ArgumentNullException(nameof(statisticsService)); - RefreshCommand = new RelayCommand( + RefreshCommand = new AsyncRelayCommand( async () => await LoadDashboardDataAsync().ConfigureAwait(false), () => !IsLoading); InitializeChartAxes(); } - #region Properties - public bool IsLoading { get => _isLoading; @@ -57,7 +54,7 @@ public bool IsLoading { if (SetProperty(ref _isLoading, value)) { - (RefreshCommand as RelayCommand)?.OnCanExecuteChanged(); + (RefreshCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } } } @@ -153,8 +150,6 @@ public ISeries[] TopEventIdsSeries private set => SetProperty(ref _topEventIdsSeries, value); } - #endregion - public ICommand RefreshCommand { get; } private void InitializeChartAxes() @@ -228,7 +223,7 @@ public async Task LoadSessionsAsync() { try { - var sessions = await _repository.GetSessionIdsAsync().ConfigureAwait(false); + var sessions = await _statisticsService.GetSessionsAsync().ConfigureAwait(false); await Dispatcher.UIThread.InvokeAsync(() => { @@ -239,7 +234,10 @@ await Dispatcher.UIThread.InvokeAsync(() => AvailableSessions.Add(session); } - SelectedSession = "All Sessions"; + if (string.IsNullOrEmpty(SelectedSession)) + { + SelectedSession = "All Sessions"; + } }); } catch (Exception ex) @@ -250,6 +248,15 @@ await Dispatcher.UIThread.InvokeAsync(() => public async Task LoadDashboardDataAsync() { + if (_loadingCts != null) + { + await _loadingCts.CancelAsync().ConfigureAwait(false); + _loadingCts.Dispose(); + } + + _loadingCts = new CancellationTokenSource(); + var token = _loadingCts.Token; + IsLoading = true; StatusMessage = "Loading dashboard data..."; @@ -257,36 +264,42 @@ public async Task LoadDashboardDataAsync() { var sessionId = SelectedSession == "All Sessions" ? null : SelectedSession; - var stats = await _repository.GetDetailedStatisticsAsync(sessionId).ConfigureAwait(false); - Statistics = stats; + var statsTask = _statisticsService.GetStatisticsAsync(sessionId, token); + var timeSeriesTask = _statisticsService.GetTimeSeriesAsync(sessionId, TimeSpan.FromHours(1), token); + var topSourcesTask = _statisticsService.GetTopSourcesAsync(10, sessionId, token); + var topEventIdsTask = _statisticsService.GetTopEventIdsAsync(10, sessionId, token); - var timeSeries = await _repository.GetLogsTimeSeriesAsync( - sessionId, - groupBy: TimeSpan.FromHours(1)).ConfigureAwait(false); + await Task.WhenAll(statsTask, timeSeriesTask, topSourcesTask, topEventIdsTask).ConfigureAwait(false); - var topSources = await _repository.GetTopSourcesAsync(10, sessionId).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); - var topEventIds = await _repository.GetTopEventIdsAsync(10, sessionId).ConfigureAwait(false); + var stats = await statsTask; + var timeSeries = await timeSeriesTask; + var topSources = await topSourcesTask; + var topEventIds = await topEventIdsTask; await Dispatcher.UIThread.InvokeAsync(() => { + Statistics = stats; UpdateLevelPieChart(stats); UpdateTimelineChart(timeSeries); UpdateTopSourcesChart(topSources); UpdateTopEventIdsChart(topEventIds); - OnPropertyChanged(nameof(TotalLogs)); - OnPropertyChanged(nameof(ErrorCount)); - OnPropertyChanged(nameof(WarningCount)); - OnPropertyChanged(nameof(InformationCount)); + OnPropertiesChanged(nameof(TotalLogs), nameof(ErrorCount), nameof(WarningCount), + nameof(InformationCount)); StatusMessage = $"Dashboard loaded. Total: {stats.TotalLogs} logs"; }); } + catch (OperationCanceledException) + { + Debug.WriteLine("Dashboard loading was cancelled"); + } catch (Exception ex) { Debug.WriteLine($"Error loading dashboard: {ex.Message}"); - await Dispatcher.UIThread.InvokeAsync(() => { StatusMessage = $"Error: {ex.Message}"; }); + await Dispatcher.UIThread.InvokeAsync(() => StatusMessage = $"Error: {ex.Message}"); } finally { @@ -294,6 +307,11 @@ await Dispatcher.UIThread.InvokeAsync(() => } } + public void InvalidateCache() + { + _statisticsService.InvalidateCache(); + } + private void UpdateLevelPieChart(LogStatistics stats) { var data = new List<(string Name, int Value, SKColor Color)> @@ -306,16 +324,24 @@ private void UpdateLevelPieChart(LogStatistics stats) ("Other", stats.OtherCount, SKColors.Gray) }; - LevelPieSeries = data - .Where(d => d.Value > 0) - .Select(d => new PieSeries + var filteredData = data.Where(d => d.Value > 0).ToList(); + + if (filteredData.Count == 0) + { + LevelPieSeries = []; + return; + } + + LevelPieSeries = filteredData + .Select(d => new PieSeries { Name = d.Name, - Values = new[] { d.Value }, + Values = new ObservableValue[] { new(d.Value) }, Fill = new SolidColorPaint(d.Color), - DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer, + DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Middle, DataLabelsPaint = new SolidColorPaint(SKColors.White), - DataLabelsFormatter = point => $"{d.Name}: {point.Coordinate.PrimaryValue}" + DataLabelsSize = 12, + DataLabelsFormatter = point => $"{d.Name} {d.Value}" }) .Cast() .ToArray(); @@ -449,22 +475,24 @@ private static string TruncateString(string value, int maxLength) return value.Length <= maxLength ? value : value[..(maxLength - 3)] + "..."; } - #region INotifyPropertyChanged - - public event PropertyChangedEventHandler? PropertyChanged; - - private bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + private async ValueTask DisposeAsyncCore() { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(propertyName); - return true; + if (!_disposedValue) + { + if (_loadingCts != null) + { + await _loadingCts.CancelAsync().ConfigureAwait(false); + _loadingCts.Dispose(); + _loadingCts = null; + } + + _disposedValue = true; + } } - private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + public async ValueTask DisposeAsync() { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + await DisposeAsyncCore().ConfigureAwait(false); + GC.SuppressFinalize(this); } - - #endregion } diff --git a/LogAnalyzerForWindows/ViewModels/MainWindowViewModel.cs b/LogAnalyzerForWindows/ViewModels/MainWindowViewModel.cs index 32631a2..2780512 100644 --- a/LogAnalyzerForWindows/ViewModels/MainWindowViewModel.cs +++ b/LogAnalyzerForWindows/ViewModels/MainWindowViewModel.cs @@ -1,8 +1,7 @@ -using System.ComponentModel; +using System.Collections.Concurrent; using System.Diagnostics; using System.Net.Mail; using System.Net.Sockets; -using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Windows.Input; using Avalonia.Collections; @@ -12,42 +11,50 @@ using LogAnalyzerForWindows.Database.Repositories; using LogAnalyzerForWindows.Filter; using LogAnalyzerForWindows.Formatter; -using LogAnalyzerForWindows.Formatter.Interfaces; -using LogAnalyzerForWindows.Helpers; using LogAnalyzerForWindows.Interfaces; using LogAnalyzerForWindows.Models; using LogAnalyzerForWindows.Models.Analyzer; using LogAnalyzerForWindows.Models.Reader; -using LogAnalyzerForWindows.Models.Reader.Interfaces; -using LogAnalyzerForWindows.Models.Writer; -using LogAnalyzerForWindows.Models.Writer.Interfaces; -using LogAnalyzerForWindows.Views; namespace LogAnalyzerForWindows.ViewModels; -internal sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable +internal sealed class MainWindowViewModel : ViewModelBase, IAsyncDisposable, IDisposable { private readonly IEmailService _emailService; private readonly IDialogService _dialogService; + private readonly ILogExportService _exportService; private readonly IFileSystemService _fileSystemService; private readonly ILogMonitor _monitor; private readonly ILogRepository _logRepository; + private readonly ILogStatisticsService _statisticsService; private readonly FileSystemWatcher _folderWatcher; private readonly Func _paginationViewModelFactory; + private readonly LogFormatter _formatter; private string _selectedLogLevel = string.Empty; private string _selectedLogSource = string.Empty; private string _selectedTime = string.Empty; - private ICommand? _startCommand; - private ICommand? _stopCommand; private ICommand? _sendEmailCommand; private EventHandler? _onLogsChangedHandler; - public bool IsMonitoring => _monitor.IsMonitoring; - private readonly HashSet _processedLogs = []; + private readonly ConcurrentDictionary _processedLogs = new(); private CancellationTokenSource? _processingCts; private DashboardViewModel? _dashboardViewModel; + private string _textBlock = string.Empty; + private string _outputText = string.Empty; + private string _selectedFormat = "TXT"; + private bool _isLoading; + private bool _canSave; + private string _userEmail = string.Empty; + private string _currentSessionId = string.Empty; + private PaginationViewModel? _paginationViewModel; + private bool _useDatabaseMode; + private AvaloniaList _availableSessions = new(); + private string? _selectedSession; + private bool _hasDatabaseRecords; + + public bool IsMonitoring => _monitor.IsMonitoring; public DashboardViewModel? DashboardViewModel { @@ -58,9 +65,7 @@ public DashboardViewModel? DashboardViewModel public AvaloniaList LogSources { get; } = new(); public AvaloniaList LogLevels { get; private set; } = new(); public AvaloniaList Times { get; } = ["Last hour", "Last 24 hours", "Last 3 days", "Last 7 days"]; - public AvaloniaList Formats { get; } = ["txt", "json"]; - - private string _textBlock = string.Empty; + public AvaloniaList Formats { get; } = ["TXT", "JSON"]; public string TextBlock { @@ -68,8 +73,6 @@ public string TextBlock set => SetProperty(ref _textBlock, value); } - private string _outputText = string.Empty; - public string OutputText { get => _outputText; @@ -83,7 +86,7 @@ public string SelectedLogSource { if (SetProperty(ref _selectedLogSource, value)) { - LoadAvailableLevelsForSource(); + _ = LoadAvailableLevelsForSourceAsync(); (StartCommand as RelayCommand)?.OnCanExecuteChanged(); } } @@ -113,8 +116,6 @@ public string SelectedTime } } - private string _selectedFormat = "txt"; - public string SelectedFormat { get => _selectedFormat; @@ -123,29 +124,23 @@ public string SelectedFormat if (SetProperty(ref _selectedFormat, value)) { UpdateCanSaveState(); - (ExportSessionCommand as RelayCommand)?.OnCanExecuteChanged(); + (ExportSessionCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } } } - private bool _isLoading; - public bool IsLoading { get => _isLoading; set => SetProperty(ref _isLoading, value); } - private bool _canSave; - public bool CanSave { get => _canSave; private set => SetProperty(ref _canSave, value); } - private string _userEmail = string.Empty; - public string UserEmail { get => _userEmail; @@ -153,7 +148,7 @@ public string UserEmail { if (SetProperty(ref _userEmail, value)) { - (_sendEmailCommand as RelayCommand)?.OnCanExecuteChanged(); + (_sendEmailCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } } } @@ -163,17 +158,8 @@ public string UserEmail public static bool IsFolderExists => Directory.Exists(DefaultLogFolderPath); - public ICommand? StartCommand - { - get => _startCommand; - set => SetProperty(ref _startCommand, value); - } - - public ICommand? StopCommand - { - get => _stopCommand; - set => SetProperty(ref _stopCommand, value); - } + public ICommand StartCommand { get; } + public ICommand StopCommand { get; } private readonly ISettingsService _settingsService; public ICommand OpenSettingsCommand { get; } @@ -181,54 +167,13 @@ public ICommand? StopCommand public ICommand OpenFolderCommand { get; } public ICommand ArchiveLatestFolderCommand { get; } public ICommand ExportSessionCommand { get; } + public ICommand DeleteSessionCommand { get; } - public ICommand SendEmailCommand => _sendEmailCommand ??= new RelayCommand( - async () => await SendEmailAsync().ConfigureAwait(false), + public ICommand SendEmailCommand => _sendEmailCommand ??= new AsyncRelayCommand( + SendEmailAsync, CanSendEmail ); - private bool CanSendEmail() - { - if (!IsValidEmail(UserEmail)) return false; - try - { - return Directory.Exists(DefaultLogFolderPath) && - Directory.GetFiles(DefaultLogFolderPath, "*.zip").Length != 0; - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Access denied checking for zip files: {ex.Message}"); - return false; - } - catch (IOException ex) - { - Debug.WriteLine($"IO error checking for zip files: {ex.Message}"); - return false; - } - } - - private static bool IsValidEmail(string email) - { - if (string.IsNullOrWhiteSpace(email)) - return false; - - try - { - var addr = new MailAddress(email); - return addr.Address == email; - } - catch (FormatException) - { - return false; - } - } - - private string _currentSessionId = string.Empty; - private PaginationViewModel? _paginationViewModel; - private bool _useDatabaseMode; - private AvaloniaList _availableSessions = new(); - private string? _selectedSession; - public PaginationViewModel? PaginationViewModel { get => _paginationViewModel; @@ -249,13 +194,12 @@ public string? SelectedSession if (SetProperty(ref _selectedSession, value)) { ApplySessionFilter(); - (ExportSessionCommand as RelayCommand)?.OnCanExecuteChanged(); + (ExportSessionCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); + (DeleteSessionCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } } } - private bool _hasDatabaseRecords; - public bool HasDatabaseRecords { get => _hasDatabaseRecords; @@ -263,7 +207,7 @@ private set { if (SetProperty(ref _hasDatabaseRecords, value)) { - (ClearHistoryCommand as RelayCommand)?.OnCanExecuteChanged(); + (ClearHistoryCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } } } @@ -271,6 +215,31 @@ private set public ICommand ViewHistoryCommand { get; } public ICommand ClearHistoryCommand { get; } + public bool UseDatabaseMode + { + get => _useDatabaseMode; + set + { + if (SetProperty(ref _useDatabaseMode, value)) + { + if (value) + { + InitializeDatabaseMode(); + } + else + { + PaginationViewModel = null; + } + + (StartCommand as RelayCommand)?.OnCanExecuteChanged(); + (StopCommand as RelayCommand)?.OnCanExecuteChanged(); + (ClearHistoryCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); + } + } + } + + public bool CanToggleDatabaseMode => !_monitor.IsMonitoring; + public MainWindowViewModel( IEmailService emailService, IFileSystemService fileSystemService, @@ -278,6 +247,8 @@ public MainWindowViewModel( ILogRepository logRepository, ISettingsService settingsService, IDialogService dialogService, + ILogStatisticsService statisticsService, + ILogExportService exportService, Func paginationViewModelFactory) { _emailService = emailService ?? throw new ArgumentNullException(nameof(emailService)); @@ -286,22 +257,25 @@ public MainWindowViewModel( _logRepository = logRepository ?? throw new ArgumentNullException(nameof(logRepository)); _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + _exportService = exportService ?? throw new ArgumentNullException(nameof(exportService)); + _statisticsService = statisticsService ?? throw new ArgumentNullException(nameof(statisticsService)); _paginationViewModelFactory = paginationViewModelFactory ?? throw new ArgumentNullException(nameof(paginationViewModelFactory)); + _formatter = new LogFormatter(); _monitor.MonitoringStarted += OnMonitoringStateChanged; _monitor.MonitoringStopped += OnMonitoringStateChanged; - OpenSettingsCommand = new RelayCommand(async () => await OpenSettingsAsync().ConfigureAwait(false)); + OpenSettingsCommand = new AsyncRelayCommand(OpenSettingsAsync); StartCommand = new RelayCommand(StartMonitoring, CanStartMonitoring); StopCommand = new RelayCommand(StopMonitoring, CanStopMonitoring); - SaveCommand = new RelayCommand(SaveLogs, () => CanSave); + SaveCommand = new AsyncRelayCommand(SaveLogsAsync, () => CanSave); OpenFolderCommand = new RelayCommand(OpenLogFolder, () => IsFolderExists); - ArchiveLatestFolderCommand = new RelayCommand(ArchiveLogFolder, () => IsFolderExists); - ExportSessionCommand = new RelayCommand( - async () => await ExportSessionLogsAsync().ConfigureAwait(false), - CanExportSession - ); + ArchiveLatestFolderCommand = new AsyncRelayCommand(ArchiveLogFolderAsync, () => IsFolderExists); + ExportSessionCommand = new AsyncRelayCommand(ExportSessionLogsAsync, CanExportSession); + DeleteSessionCommand = new AsyncRelayCommand(DeleteSessionAsync, CanDeleteSession); + ViewHistoryCommand = new AsyncRelayCommand(ViewHistoryAsync); + ClearHistoryCommand = new AsyncRelayCommand(ClearOldHistoryAsync, CanClearHistory); var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); var logPath = Path.Combine(documentsPath, "AzioEventLogAnalyzer"); @@ -339,25 +313,65 @@ public MainWindowViewModel( OnPropertyChanged(nameof(IsFolderExists)); UpdateCanSaveState(); - ViewHistoryCommand = new RelayCommand(async () => await ViewHistoryAsync().ConfigureAwait(false)); + _ = InitializeAsync(); + } + + private async Task InitializeAsync() + { + try + { + await InitializeDatabaseAsync().ConfigureAwait(false); + await CheckDatabaseRecordsAsync().ConfigureAwait(false); + await LoadAvailableLogSourcesAsync().ConfigureAwait(false); - ClearHistoryCommand = new RelayCommand( - async () => await ClearOldHistoryAsync().ConfigureAwait(false), - CanClearHistory - ); + DashboardViewModel = new DashboardViewModel(_statisticsService); + await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); + await DashboardViewModel.LoadDashboardDataAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"Initialization error: {ex.Message}"); + } + } - InitializeDatabaseAsync(); - _ = CheckDatabaseRecordsAsync(); + private bool CanSendEmail() + { + if (!IsValidEmail(UserEmail)) return false; + try + { + return Directory.Exists(DefaultLogFolderPath) && + Directory.GetFiles(DefaultLogFolderPath, "*.zip").Length != 0; + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Access denied checking for zip files: {ex.Message}"); + return false; + } + catch (IOException ex) + { + Debug.WriteLine($"IO error checking for zip files: {ex.Message}"); + return false; + } + } - LoadAvailableLogSources(); + private static bool IsValidEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; - DashboardViewModel = new DashboardViewModel(logRepository); - _ = DashboardViewModel.LoadSessionsAsync(); - _ = DashboardViewModel.LoadDashboardDataAsync(); + try + { + var addr = new MailAddress(email); + return addr.Address == email; + } + catch (FormatException) + { + return false; + } } [SupportedOSPlatform("windows")] - private async void LoadAvailableLogSources() + private async Task LoadAvailableLogSourcesAsync() { try { @@ -392,7 +406,7 @@ await Dispatcher.UIThread.InvokeAsync(() => } [SupportedOSPlatform("windows")] - private async void LoadAvailableLevelsForSource() + private async Task LoadAvailableLevelsForSourceAsync() { if (string.IsNullOrEmpty(SelectedLogSource)) { @@ -445,7 +459,7 @@ await Dispatcher.UIThread.InvokeAsync(() => } } - private static async void InitializeDatabaseAsync() + private static async Task InitializeDatabaseAsync() { try { @@ -528,31 +542,6 @@ private async Task CheckDatabaseRecordsAsync() } } - public bool UseDatabaseMode - { - get => _useDatabaseMode; - set - { - if (SetProperty(ref _useDatabaseMode, value)) - { - if (value) - { - InitializeDatabaseMode(); - } - else - { - PaginationViewModel = null; - } - - (StartCommand as RelayCommand)?.OnCanExecuteChanged(); - (StopCommand as RelayCommand)?.OnCanExecuteChanged(); - (ClearHistoryCommand as RelayCommand)?.OnCanExecuteChanged(); - } - } - } - - public bool CanToggleDatabaseMode => !_monitor.IsMonitoring; - private bool CanClearHistory() { return UseDatabaseMode && HasDatabaseRecords; @@ -560,8 +549,8 @@ private bool CanClearHistory() private void UpdateCanSaveState() { - CanSave = _processedLogs.Count != 0 && !string.IsNullOrEmpty(SelectedFormat); - (SaveCommand as RelayCommand)?.OnCanExecuteChanged(); + CanSave = !_processedLogs.IsEmpty && !string.IsNullOrEmpty(SelectedFormat); + (SaveCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } private bool CanStartMonitoring() => @@ -590,12 +579,8 @@ private void StartMonitoring() _currentSessionId = $"Session_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{SelectedLogSource}"; - ILogReader reader = new WindowsEventLogReader(SelectedLogSource); - var generalAnalyzer = new LevelLogAnalyzer(SelectedLogLevel); - ILogFormatter formatter = new LogFormatter(); - ILogWriter writer = new TextBoxLogWriter(formatter, UpdateOutputTextOnUiThread); - - var manager = new LogManager(reader, generalAnalyzer, formatter, writer); + var reader = new WindowsEventLogReader(SelectedLogSource); + var levelAnalyzer = new LevelLogAnalyzer(SelectedLogLevel); var timeSpan = SelectedTime switch { @@ -624,33 +609,16 @@ private void StartMonitoring() if (_processingCts == null || _processingCts.IsCancellationRequested) return; - var incomingLogs = args.Logs; - var relevantLogs = timeFilter.Filter(incomingLogs); - var levelAnalyzer = new LevelLogAnalyzer(SelectedLogLevel); + var relevantLogs = timeFilter.Filter(args.Logs); var newUniqueLevelLogs = levelAnalyzer.FilterByLevel(relevantLogs) - .Where(_processedLogs.Add) + .Where(log => _processedLogs.TryAdd(log, 0)) + .OrderBy(log => log.Timestamp) .ToList(); if (newUniqueLevelLogs.Count > 0) { - _ = Task.Run(async () => - { - try - { - await _logRepository.SaveLogsAsync(newUniqueLevelLogs, _currentSessionId).ConfigureAwait(false); - Debug.WriteLine($"Bulk saved {newUniqueLevelLogs.Count} logs to database"); - await CheckDatabaseRecordsAsync().ConfigureAwait(false); - } - catch (InvalidOperationException ex) - { - Debug.WriteLine($"Error saving logs to database: {ex.Message}"); - } - catch (IOException ex) - { - Debug.WriteLine($"IO error saving logs to database: {ex.Message}"); - } - }, _processingCts.Token); + _ = SaveLogsToDatabaseAsync(newUniqueLevelLogs); const int uiBatchSize = 50; for (int i = 0; i < newUniqueLevelLogs.Count; i += uiBatchSize) @@ -661,32 +629,37 @@ private void StartMonitoring() var batch = newUniqueLevelLogs.Skip(i).Take(uiBatchSize).ToList(); var batchIndex = i; - await Dispatcher.UIThread.InvokeAsync(async () => + await Dispatcher.UIThread.InvokeAsync(() => { - try - { - UpdateCanSaveState(); - await manager.ProcessLogsAsync(batch, _processingCts.Token).ConfigureAwait(false); - - var matchingCount = _processedLogs.Count(l => - string.Equals(l.Level, SelectedLogLevel, StringComparison.OrdinalIgnoreCase)); - TextBlock = - $"Monitoring {SelectedLogSource}... '{SelectedLogLevel}' logs: {matchingCount} " + - $"(Processing batch {batchIndex / uiBatchSize + 1}/{(newUniqueLevelLogs.Count + uiBatchSize - 1) / uiBatchSize})"; - } - catch (OperationCanceledException) + UpdateCanSaveState(); + + foreach (var log in batch) { - Debug.WriteLine("Processing cancelled"); + var formattedLog = _formatter.Format(log); + OutputText += formattedLog + Environment.NewLine; } - }).ConfigureAwait(false); - await Task.Delay(10, _processingCts.Token).ConfigureAwait(false); + var matchingCount = _processedLogs.Count(l => + string.Equals(l.Key.Level, SelectedLogLevel, StringComparison.OrdinalIgnoreCase)); + TextBlock = + $"Monitoring {SelectedLogSource}... '{SelectedLogLevel}' logs: {matchingCount} " + + $"(Processing batch {batchIndex / uiBatchSize + 1}/{(newUniqueLevelLogs.Count + uiBatchSize - 1) / uiBatchSize})"; + }); + + try + { + await Task.Delay(10, _processingCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } } await Dispatcher.UIThread.InvokeAsync(() => { var matchingCount = _processedLogs.Count(l => - string.Equals(l.Level, SelectedLogLevel, StringComparison.OrdinalIgnoreCase)); + string.Equals(l.Key.Level, SelectedLogLevel, StringComparison.OrdinalIgnoreCase)); TextBlock = $"Monitoring {SelectedLogSource}... Unique '{SelectedLogLevel}' logs found: {matchingCount} (Session: {_currentSessionId})"; }); @@ -697,6 +670,32 @@ await Dispatcher.UIThread.InvokeAsync(() => _monitor.Monitor(reader); } + private async Task SaveLogsToDatabaseAsync(List logs) + { + try + { + await _logRepository.SaveLogsAsync(logs, _currentSessionId).ConfigureAwait(false); + Debug.WriteLine($"Bulk saved {logs.Count} logs to database"); + + _statisticsService.InvalidateCache(_currentSessionId); + + await CheckDatabaseRecordsAsync().ConfigureAwait(false); + + if (DashboardViewModel != null) + { + await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); + } + } + catch (InvalidOperationException ex) + { + Debug.WriteLine($"Error saving logs to database: {ex.Message}"); + } + catch (IOException ex) + { + Debug.WriteLine($"IO error saving logs to database: {ex.Message}"); + } + } + private async Task ViewHistoryAsync() { await Dispatcher.UIThread.InvokeAsync(() => @@ -772,6 +771,8 @@ await Dispatcher.UIThread.InvokeAsync(() => { var deletedCount = await _logRepository.ClearAllLogsAsync().ConfigureAwait(false); + _statisticsService.InvalidateCache(); + await Dispatcher.UIThread.InvokeAsync(() => { TextBlock = $"Deleted {deletedCount} log entries. Database cleared."; @@ -785,6 +786,12 @@ await Dispatcher.UIThread.InvokeAsync(async () => { await PaginationViewModel.LoadLogsAsync().ConfigureAwait(false); } + + if (DashboardViewModel != null) + { + await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); + await DashboardViewModel.LoadDashboardDataAsync().ConfigureAwait(false); + } }).ConfigureAwait(false); } catch (InvalidOperationException ex) @@ -829,9 +836,9 @@ private void StopMonitoring() } } - private void SaveLogs() + private async Task SaveLogsAsync() { - if (string.IsNullOrEmpty(SelectedFormat) || _processedLogs.Count == 0) + if (string.IsNullOrEmpty(SelectedFormat) || _processedLogs.IsEmpty) { TextBlock = "No logs to save or format not selected."; return; @@ -840,44 +847,26 @@ private void SaveLogs() TextBlock = "Saving logs..."; IsLoading = true; - Task.Run(() => + try { - try - { - ILogFormatter formatter = SelectedFormat.ToUpperInvariant() switch - { - "JSON" => new JsonLogFormatter(), - "TXT" => new LogFormatter(), - _ => throw new InvalidOperationException($"Unknown format: {SelectedFormat}") - }; - - var linesToSave = _processedLogs.Select(log => - { - var formattedResult = formatter.Format(log); - return formattedResult.ToString() ?? string.Empty; - }); - - var logsContent = string.Join(Environment.NewLine, linesToSave); - var filePath = LogPathHelper.GetLogFilePath(SelectedFormat); - File.WriteAllText(filePath, logsContent); + var filePath = await _exportService.ExportLogsAsync( + _processedLogs.Keys, + SelectedFormat + ).ConfigureAwait(false); - Dispatcher.UIThread.InvokeAsync(() => TextBlock = $"Logs saved to: {filePath}"); - } - catch (IOException ex) - { - Debug.WriteLine($"IO error saving logs: {ex.Message}"); - Dispatcher.UIThread.InvokeAsync(() => TextBlock = $"Error saving logs: {ex.Message}"); - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Access denied saving logs: {ex.Message}"); - Dispatcher.UIThread.InvokeAsync(() => TextBlock = $"Access denied: {ex.Message}"); - } - finally - { - Dispatcher.UIThread.InvokeAsync(() => IsLoading = false); - } - }); + await Dispatcher.UIThread.InvokeAsync(() => + TextBlock = $"Logs saved to: {filePath}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Debug.WriteLine($"Error saving logs: {ex.Message}"); + await Dispatcher.UIThread.InvokeAsync(() => + TextBlock = $"Error saving logs: {ex.Message}"); + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => IsLoading = false); + } } private void OpenLogFolder() @@ -885,18 +874,20 @@ private void OpenLogFolder() _fileSystemService.OpenFolder(DefaultLogFolderPath, UpdateTextBlockOnUiThread); } - private void ArchiveLogFolder() + private async Task ArchiveLogFolderAsync() { TextBlock = "Archiving..."; IsLoading = true; - Task.Run(() => + + await Task.Run(() => { _fileSystemService.ArchiveLatestFolder(DefaultLogFolderPath, UpdateTextBlockOnUiThread); - Dispatcher.UIThread.InvokeAsync(() => - { - IsLoading = false; - (_sendEmailCommand as RelayCommand)?.OnCanExecuteChanged(); - }); + }).ConfigureAwait(false); + + await Dispatcher.UIThread.InvokeAsync(() => + { + IsLoading = false; + (_sendEmailCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); }); } @@ -944,17 +935,17 @@ await _emailService.SendEmailAsync( Debug.WriteLine($"SMTP error sending email: {smtpEx.StatusCode} - {smtpEx.Message}"); var userMessage = smtpEx.InnerException is SocketException - ? "Error sending email: Network connection issue or email server unavailable. Please check your internet connection and server status." + ? "Error sending email: Network connection issue or email server unavailable." : smtpEx.StatusCode switch { SmtpStatusCode.MailboxUnavailable => "Error sending email: Recipient mailbox unavailable or does not exist.", SmtpStatusCode.ServiceNotAvailable => - "Error sending email: Email service is temporarily unavailable. Please try again later.", + "Error sending email: Email service is temporarily unavailable.", SmtpStatusCode.ClientNotPermitted or SmtpStatusCode.TransactionFailed => - "Error sending email: Authentication failed or transaction rejected by the email server. Please check your email credentials and server policy.", + "Error sending email: Authentication failed or transaction rejected.", SmtpStatusCode.MustIssueStartTlsFirst => - "Error sending email: Secure connection (TLS) required by the server was not established.", + "Error sending email: Secure connection (TLS) required but not established.", _ => $"SMTP Error: {smtpEx.Message}" }; @@ -977,14 +968,14 @@ private void OnLogDirectoryChanged(object sender, FileSystemEventArgs e) { OnPropertyChanged(nameof(IsFolderExists)); (OpenFolderCommand as RelayCommand)?.OnCanExecuteChanged(); - (ArchiveLatestFolderCommand as RelayCommand)?.OnCanExecuteChanged(); - (_sendEmailCommand as RelayCommand)?.OnCanExecuteChanged(); + (ArchiveLatestFolderCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); + (_sendEmailCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); }); } - private void OnMonitoringStateChanged(object? sender, EventArgs e) + private async void OnMonitoringStateChanged(object? sender, EventArgs e) { - Dispatcher.UIThread.InvokeAsync(() => + await Dispatcher.UIThread.InvokeAsync(async () => { (StartCommand as RelayCommand)?.OnCanExecuteChanged(); (StopCommand as RelayCommand)?.OnCanExecuteChanged(); @@ -1001,6 +992,14 @@ private void OnMonitoringStateChanged(object? sender, EventArgs e) { TextBlock = "Monitoring stopped."; IsLoading = false; + + _statisticsService.InvalidateCache(); + + if (DashboardViewModel != null) + { + await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); + await DashboardViewModel.LoadDashboardDataAsync().ConfigureAwait(false); + } } }); } @@ -1036,29 +1035,7 @@ await Dispatcher.UIThread.InvokeAsync(() => try { - var allLogs = new List(); - var pageSize = 1000; - var currentPage = 1; - int totalCount; - - do - { - var (logs, count) = await _logRepository.GetLogsAsync( - currentPage, - pageSize, - levelFilter: null, - startDate: null, - endDate: null, - sessionId: SelectedSession - ).ConfigureAwait(false); - - allLogs.AddRange(logs); - totalCount = count; - currentPage++; - - await Dispatcher.UIThread.InvokeAsync(() => - TextBlock = $"Loading session logs... {allLogs.Count}/{totalCount}"); - } while (allLogs.Count < totalCount); + var allLogs = await LoadAllSessionLogsAsync(SelectedSession).ConfigureAwait(false); if (allLogs.Count == 0) { @@ -1070,60 +1047,20 @@ await Dispatcher.UIThread.InvokeAsync(() => return; } - await Task.Run(() => - { - try - { - ILogFormatter formatter = SelectedFormat.ToUpperInvariant() switch - { - "JSON" => new JsonLogFormatter(), - "TXT" => new LogFormatter(), - _ => throw new InvalidOperationException($"Unknown format: {SelectedFormat}") - }; - - var linesToSave = allLogs.Select(log => - { - var formattedResult = formatter.Format(log); - return formattedResult.ToString() ?? string.Empty; - }); - - var logsContent = string.Join(Environment.NewLine, linesToSave); - - var safeSessionName = string.Join("_", - SelectedSession.Split(Path.GetInvalidFileNameChars())); - var fileName = $"{safeSessionName}.{SelectedFormat}"; - var filePath = Path.Combine(DefaultLogFolderPath, fileName); - - File.WriteAllText(filePath, logsContent); + var filePath = await _exportService.ExportLogsAsync( + allLogs, + SelectedFormat, + SelectedSession + ).ConfigureAwait(false); - Dispatcher.UIThread.InvokeAsync(() => - TextBlock = $"Exported {allLogs.Count} logs from session '{SelectedSession}' to: {filePath}"); - } - catch (IOException ex) - { - Debug.WriteLine($"IO error exporting session logs: {ex.Message}"); - Dispatcher.UIThread.InvokeAsync(() => - TextBlock = $"Error exporting logs: {ex.Message}"); - } - catch (UnauthorizedAccessException ex) - { - Debug.WriteLine($"Access denied exporting session logs: {ex.Message}"); - Dispatcher.UIThread.InvokeAsync(() => - TextBlock = $"Access denied: {ex.Message}"); - } - }).ConfigureAwait(false); - } - catch (InvalidOperationException ex) - { - Debug.WriteLine($"Error loading session logs: {ex.Message}"); await Dispatcher.UIThread.InvokeAsync(() => - TextBlock = $"Error loading session logs: {ex.Message}"); + TextBlock = $"Exported {allLogs.Count} logs from session '{SelectedSession}' to: {filePath}"); } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { - Debug.WriteLine($"IO error loading session logs: {ex.Message}"); + Debug.WriteLine($"Error exporting session logs: {ex.Message}"); await Dispatcher.UIThread.InvokeAsync(() => - TextBlock = $"IO error loading session logs: {ex.Message}"); + TextBlock = $"Error exporting logs: {ex.Message}"); } finally { @@ -1131,76 +1068,187 @@ await Dispatcher.UIThread.InvokeAsync(() => } } - private void UpdateTextBlockOnUiThread(string message) + private async Task> LoadAllSessionLogsAsync(string sessionId) { - Dispatcher.UIThread.InvokeAsync(() => TextBlock = message); + var allLogs = new List(); + const int pageSize = 1000; + var currentPage = 1; + int totalCount; + + do + { + var (logs, count) = await _logRepository.GetLogsAsync( + currentPage, + pageSize, + levelFilter: null, + startDate: null, + endDate: null, + sessionId: sessionId + ).ConfigureAwait(false); + + allLogs.AddRange(logs); + totalCount = count; + currentPage++; + + await Dispatcher.UIThread.InvokeAsync(() => + TextBlock = $"Loading session logs... {allLogs.Count}/{totalCount}"); + } while (allLogs.Count < totalCount); + + return allLogs; } - private void UpdateOutputTextOnUiThread(string text) + private bool CanDeleteSession() { - Dispatcher.UIThread.InvokeAsync(() => OutputText += text + Environment.NewLine); + return UseDatabaseMode && + !string.IsNullOrEmpty(SelectedSession) && + SelectedSession != "All Sessions"; } - public event PropertyChangedEventHandler? PropertyChanged; - - private bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + private async Task DeleteSessionAsync() { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(propertyName); - return true; + if (string.IsNullOrEmpty(SelectedSession) || SelectedSession == "All Sessions") + { + await Dispatcher.UIThread.InvokeAsync(() => + TextBlock = "Please select a specific session to delete."); + return; + } + + var sessionToDelete = SelectedSession; + + await Dispatcher.UIThread.InvokeAsync(() => + { + IsLoading = true; + TextBlock = $"Deleting session '{sessionToDelete}'..."; + }); + + try + { + var deletedCount = await _logRepository.DeleteSessionAsync(sessionToDelete).ConfigureAwait(false); + + _statisticsService.InvalidateCache(sessionToDelete); + _statisticsService.InvalidateCache(); + + await Dispatcher.UIThread.InvokeAsync(async () => + { + TextBlock = $"Deleted {deletedCount} logs from session '{sessionToDelete}'."; + + AvailableSessions.Remove(sessionToDelete); + SelectedSession = "All Sessions"; + + if (PaginationViewModel != null) + { + await PaginationViewModel.LoadLogsAsync().ConfigureAwait(false); + } + + if (DashboardViewModel != null) + { + await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); + await DashboardViewModel.LoadDashboardDataAsync().ConfigureAwait(false); + } + }); + + await CheckDatabaseRecordsAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException) + { + Debug.WriteLine($"Error deleting session: {ex.Message}"); + await Dispatcher.UIThread.InvokeAsync(() => + TextBlock = $"Error deleting session: {ex.Message}"); + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => IsLoading = false); + } } - private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + private void UpdateTextBlockOnUiThread(string message) { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + Dispatcher.UIThread.InvokeAsync(() => TextBlock = message); } private bool _disposedValue; - private void Dispose(bool disposing) + public async ValueTask DisposeAsync() { if (_disposedValue) return; - if (disposing) + try { - try + if (_monitor.IsMonitoring) { - if (_monitor.IsMonitoring) - { - StopMonitoring(); - } + StopMonitoring(); + } - _processingCts?.Cancel(); - _processingCts?.Dispose(); + if (_processingCts != null) + { + await _processingCts.CancelAsync().ConfigureAwait(false); + _processingCts.Dispose(); + } - _monitor.MonitoringStarted -= OnMonitoringStateChanged; - _monitor.MonitoringStopped -= OnMonitoringStateChanged; + _monitor.MonitoringStarted -= OnMonitoringStateChanged; + _monitor.MonitoringStopped -= OnMonitoringStateChanged; - _folderWatcher.Created -= OnLogDirectoryChanged; - _folderWatcher.Deleted -= OnLogDirectoryChanged; - _folderWatcher.Renamed -= OnLogDirectoryChanged; - _folderWatcher.Changed -= OnLogDirectoryChanged; - _folderWatcher.EnableRaisingEvents = false; - _folderWatcher.Dispose(); + _folderWatcher.Created -= OnLogDirectoryChanged; + _folderWatcher.Deleted -= OnLogDirectoryChanged; + _folderWatcher.Renamed -= OnLogDirectoryChanged; + _folderWatcher.Changed -= OnLogDirectoryChanged; + _folderWatcher.EnableRaisingEvents = false; + _folderWatcher.Dispose(); - if (_monitor is IDisposable disposableMonitor) - { - disposableMonitor.Dispose(); - } + if (_monitor is IDisposable disposableMonitor) + { + disposableMonitor.Dispose(); } - catch (ObjectDisposedException ex) + + if (DashboardViewModel != null) { - Debug.WriteLine($"Object already disposed during cleanup: {ex.Message}"); + await DashboardViewModel.DisposeAsync().ConfigureAwait(false); } } + catch (ObjectDisposedException ex) + { + Debug.WriteLine($"Object already disposed during cleanup: {ex.Message}"); + } _disposedValue = true; + GC.SuppressFinalize(this); } public void Dispose() { - Dispose(disposing: true); + if (_disposedValue) return; + + try + { + if (_monitor.IsMonitoring) + { + StopMonitoring(); + } + + _processingCts?.Cancel(); + _processingCts?.Dispose(); + + _monitor.MonitoringStarted -= OnMonitoringStateChanged; + _monitor.MonitoringStopped -= OnMonitoringStateChanged; + + _folderWatcher.Created -= OnLogDirectoryChanged; + _folderWatcher.Deleted -= OnLogDirectoryChanged; + _folderWatcher.Renamed -= OnLogDirectoryChanged; + _folderWatcher.Changed -= OnLogDirectoryChanged; + _folderWatcher.EnableRaisingEvents = false; + _folderWatcher.Dispose(); + + if (_monitor is IDisposable disposableMonitor) + { + disposableMonitor.Dispose(); + } + } + catch (ObjectDisposedException ex) + { + Debug.WriteLine($"Object already disposed during cleanup: {ex.Message}"); + } + + _disposedValue = true; GC.SuppressFinalize(this); } } diff --git a/LogAnalyzerForWindows/ViewModels/PaginationViewModel.cs b/LogAnalyzerForWindows/ViewModels/PaginationViewModel.cs index dcad786..4ecbfec 100644 --- a/LogAnalyzerForWindows/ViewModels/PaginationViewModel.cs +++ b/LogAnalyzerForWindows/ViewModels/PaginationViewModel.cs @@ -1,6 +1,4 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.CompilerServices; +using System.Diagnostics; using System.Windows.Input; using Avalonia.Collections; using Avalonia.Threading; @@ -10,7 +8,7 @@ namespace LogAnalyzerForWindows.ViewModels; -internal sealed class PaginationViewModel : INotifyPropertyChanged +internal sealed class PaginationViewModel : ViewModelBase { private readonly ILogRepository _repository; @@ -25,8 +23,9 @@ internal sealed class PaginationViewModel : INotifyPropertyChanged private string? _searchText; private int? _eventIdFilter; private string? _sourceFilter; + private bool _isLoading; - public AvaloniaList CurrentPageLogs { get; private set; } = new(); + public AvaloniaList CurrentPageLogs { get; } = new(); public AvaloniaList PageSizes { get; } = new() { 25, 50, 100, 200, 500 }; public AvaloniaList AvailableSources { get; } = new(); public AvaloniaList AvailableEventIds { get; } = new(); @@ -115,8 +114,6 @@ public string? SourceFilter public string PageInfo => $"Page {CurrentPage} of {TotalPages} (Total: {TotalRecords} records)"; - private bool _isLoading; - public bool IsLoading { get => _isLoading; @@ -139,7 +136,7 @@ public PaginationViewModel(ILogRepository repository) PreviousPageCommand = new RelayCommand(() => CurrentPage--, () => CurrentPage > 1); NextPageCommand = new RelayCommand(() => CurrentPage++, () => CurrentPage < TotalPages); LastPageCommand = new RelayCommand(() => CurrentPage = TotalPages, () => CurrentPage < TotalPages); - RefreshCommand = new RelayCommand(async () => await LoadLogsAsync().ConfigureAwait(false)); + RefreshCommand = new AsyncRelayCommand(LoadLogsAsync); SearchCommand = new RelayCommand(ExecuteSearch); ClearFiltersCommand = new RelayCommand(ClearAllFilters); } @@ -157,10 +154,7 @@ private void ClearAllFilters() _sourceFilter = null; _levelFilter = null; - OnPropertyChanged(nameof(SearchText)); - OnPropertyChanged(nameof(EventIdFilter)); - OnPropertyChanged(nameof(SourceFilter)); - OnPropertyChanged(nameof(HasActiveFilters)); + OnPropertiesChanged(nameof(SearchText), nameof(EventIdFilter), nameof(SourceFilter), nameof(HasActiveFilters)); CurrentPage = 1; _ = LoadLogsAsync().ConfigureAwait(false); @@ -271,19 +265,4 @@ private void UpdateCommandStates() (NextPageCommand as RelayCommand)?.OnCanExecuteChanged(); (LastPageCommand as RelayCommand)?.OnCanExecuteChanged(); } - - public event PropertyChangedEventHandler? PropertyChanged; - - private bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) - { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(propertyName); - return true; - } - - private void OnPropertyChanged([CallerMemberName] string? propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } } diff --git a/LogAnalyzerForWindows/ViewModels/SettingsViewModel.cs b/LogAnalyzerForWindows/ViewModels/SettingsViewModel.cs index 75fa4ff..00f2b87 100644 --- a/LogAnalyzerForWindows/ViewModels/SettingsViewModel.cs +++ b/LogAnalyzerForWindows/ViewModels/SettingsViewModel.cs @@ -1,6 +1,4 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.CompilerServices; +using System.Diagnostics; using System.Windows.Input; using Avalonia.Threading; using LogAnalyzerForWindows.Commands; @@ -9,7 +7,7 @@ namespace LogAnalyzerForWindows.ViewModels; -internal sealed class SettingsViewModel : INotifyPropertyChanged +internal sealed class SettingsViewModel : ViewModelBase { private readonly ISettingsService _settingsService; private readonly IEmailService _emailService; @@ -33,19 +31,37 @@ internal sealed class SettingsViewModel : INotifyPropertyChanged public string SmtpServer { get => _smtpServer; - set => SetProperty(ref _smtpServer, value); + set + { + if (SetProperty(ref _smtpServer, value)) + { + UpdateCommandStates(); + } + } } public int SmtpPort { get => _smtpPort; - set => SetProperty(ref _smtpPort, value); + set + { + if (SetProperty(ref _smtpPort, value)) + { + UpdateCommandStates(); + } + } } public string FromEmail { get => _fromEmail; - set => SetProperty(ref _fromEmail, value); + set + { + if (SetProperty(ref _fromEmail, value)) + { + UpdateCommandStates(); + } + } } public string FromName @@ -57,7 +73,13 @@ public string FromName public string Password { get => _password; - set => SetProperty(ref _password, value); + set + { + if (SetProperty(ref _password, value)) + { + UpdateCommandStates(); + } + } } public bool UseTls @@ -93,19 +115,37 @@ public string StatusMessage public bool IsSaving { get => _isSaving; - set => SetProperty(ref _isSaving, value); + set + { + if (SetProperty(ref _isSaving, value)) + { + UpdateCommandStates(); + } + } } public bool IsTesting { get => _isTesting; - set => SetProperty(ref _isTesting, value); + set + { + if (SetProperty(ref _isTesting, value)) + { + UpdateCommandStates(); + } + } } public string TestEmail { get => _testEmail; - set => SetProperty(ref _testEmail, value); + set + { + if (SetProperty(ref _testEmail, value)) + { + UpdateCommandStates(); + } + } } public ICommand SaveCommand { get; } @@ -119,12 +159,12 @@ public SettingsViewModel(ISettingsService settingsService, IEmailService emailSe _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); _emailService = emailService ?? throw new ArgumentNullException(nameof(emailService)); - SaveCommand = new RelayCommand( - async () => await SaveSettingsAsync().ConfigureAwait(false), + SaveCommand = new AsyncRelayCommand( + SaveSettingsAsync, () => !IsSaving && !IsTesting); - TestSmtpCommand = new RelayCommand( - async () => await TestSmtpConnectionAsync().ConfigureAwait(false), + TestSmtpCommand = new AsyncRelayCommand( + TestSmtpConnectionAsync, CanTestSmtp); ResetToDefaultCommand = new RelayCommand(ResetToDefault); @@ -143,7 +183,7 @@ private void LoadSettings() Password = settings.Smtp.Password; UseTls = settings.Smtp.UseTls; - AutoStartWithWindows = settings.General.AutoStartWithWindows; + AutoStartWithWindows = _settingsService.IsAutoStartEnabled(); MinimizeToTray = settings.General.MinimizeToTray; StartMinimized = settings.General.StartMinimized; @@ -239,11 +279,7 @@ await Dispatcher.UIThread.InvokeAsync(() => } finally { - await Dispatcher.UIThread.InvokeAsync(() => - { - IsTesting = false; - (TestSmtpCommand as RelayCommand)?.OnCanExecuteChanged(); - }); + await Dispatcher.UIThread.InvokeAsync(() => IsTesting = false); } } @@ -263,26 +299,9 @@ private void ResetToDefault() StatusMessage = "Settings reset to defaults. Click Save to apply."; } - public event PropertyChangedEventHandler? PropertyChanged; - - private bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) - { - if (EqualityComparer.Default.Equals(field, value)) return false; - field = value; - OnPropertyChanged(propertyName); - - if (propertyName is nameof(SmtpServer) or nameof(SmtpPort) or nameof(FromEmail) - or nameof(Password) or nameof(TestEmail) or nameof(IsTesting) or nameof(IsSaving)) - { - (TestSmtpCommand as RelayCommand)?.OnCanExecuteChanged(); - (SaveCommand as RelayCommand)?.OnCanExecuteChanged(); - } - - return true; - } - - private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + private void UpdateCommandStates() { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + (TestSmtpCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); + (SaveCommand as AsyncRelayCommand)?.OnCanExecuteChanged(); } } diff --git a/LogAnalyzerForWindows/ViewModels/ViewModelBase.cs b/LogAnalyzerForWindows/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..a96393c --- /dev/null +++ b/LogAnalyzerForWindows/ViewModels/ViewModelBase.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace LogAnalyzerForWindows.ViewModels; + +internal abstract class ViewModelBase : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected void OnPropertiesChanged(params string[] propertyNames) + { + foreach (var name in propertyNames) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + } +} diff --git a/LogAnalyzerForWindows/Views/MainWindow.axaml b/LogAnalyzerForWindows/Views/MainWindow.axaml index df95292..ea3f6d8 100644 --- a/LogAnalyzerForWindows/Views/MainWindow.axaml +++ b/LogAnalyzerForWindows/Views/MainWindow.axaml @@ -24,8 +24,8 @@ @@ -46,7 +46,8 @@ - @@ -152,7 +153,7 @@ + @@ -202,35 +210,37 @@ + MinWidth="300" />