Develop#7
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request refactors and enhances the LogAnalyzerForWindows application with a focus on performance optimization, code quality, and maintainability. The changes introduce significant improvements to database query efficiency, a new caching layer for statistics, consolidated command handling, and standardized development environment configuration.
Changes:
- Optimized database queries in
LogRepositoryto use server-side grouping and aggregation instead of loading all data into memory, significantly reducing memory usage and improving performance - Introduced new service layer with
LogStatisticsService(for caching statistics queries) andLogExportService(for centralized log export functionality) - Refactored ViewModels to use a shared
ViewModelBaseclass and newAsyncRelayCommandfor cleaner async/await patterns - Migrated auto-start mechanism from Windows Registry to Task Scheduler for better reliability and cross-platform compatibility
- Enhanced database indexing with composite indexes for common query patterns
- Added
.gitattributesand updated.editorconfigfor consistent line endings and code quality settings across the development team
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| LogAnalyzerForWindows/ViewModels/ViewModelBase.cs | New base class providing shared INotifyPropertyChanged implementation for all ViewModels |
| LogAnalyzerForWindows/ViewModels/{SettingsViewModel, PaginationViewModel, MainWindowViewModel, DashboardViewModel}.cs | Refactored to inherit from ViewModelBase and use AsyncRelayCommand for async operations |
| LogAnalyzerForWindows/Commands/AsyncRelayCommand.cs | New command implementation for handling async operations in MVVM pattern |
| LogAnalyzerForWindows/Services/{LogStatisticsService, LogExportService}.cs | New services for caching statistics and centralizing export logic |
| LogAnalyzerForWindows/Services/SettingsService.cs | Replaced Registry-based auto-start with Task Scheduler implementation |
| LogAnalyzerForWindows/Database/Repositories/LogRepository.cs | Optimized queries to use database-side aggregation instead of in-memory processing |
| LogAnalyzerForWindows/Database/LogAnalyzerDbContext.cs | Added composite indexes for improved query performance |
| LogAnalyzerForWindows/Models/LogMonitor.cs | Refactored to use Channel-based producer-consumer pattern for better concurrency |
| LogAnalyzerForWindows/Models/Writer/* | Removed unused LogManager and ILogWriter abstractions |
| LogAnalyzerForWindows/Views/MainWindow.axaml | Added delete session button and formatting improvements |
| LogAnalyzerForWindows/App.axaml.cs | Updated DI configuration with proper service lifetimes and new services |
| .gitattributes | New file enforcing consistent line endings across platforms |
| .editorconfig | Updated with migration-specific rules and code quality settings |
Comments suppressed due to low confidence (1)
LogAnalyzerForWindows/Migrations/20260203190033_Initial.Designer.cs:15
- The migration class is named
Initialbut the Migration attribute references20260203190033_Initial. The class name should typically match the migration name. While this will work, it could cause confusion when referencing migrations by name.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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); |
There was a problem hiding this comment.
The MainWindowViewModel implements both IAsyncDisposable and IDisposable, but the Dispose() method doesn't call DisposeAsync() or dispose of the DashboardViewModel. This creates an inconsistent disposal pattern where calling Dispose() synchronously leaves the DashboardViewModel undisposed, potentially causing resource leaks.
According to the IAsyncDisposable pattern guidelines, when a class implements both interfaces, the synchronous Dispose() method should typically call DisposeAsync().AsTask().GetAwaiter().GetResult() or implement a shared disposal logic that both methods can call. Alternatively, you should document that consumers must call DisposeAsync() instead of Dispose().
| 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); | |
| DisposeAsync().AsTask().GetAwaiter().GetResult(); |
| } | ||
|
|
||
| return logs.ToList(); | ||
| return logs.OrderBy(l => l.Timestamp).ToList(); |
There was a problem hiding this comment.
Sorting logs by timestamp at the end of reading (line 436) is good for consistency, but it adds O(n log n) complexity to every read operation. For large log sets, this could be a performance bottleneck. Consider whether sorting is always necessary, or if it could be done on-demand when the user needs sorted results.
| return logs.OrderBy(l => l.Timestamp).ToList(); | |
| // Materialize the concurrent collection into a list | |
| var result = logs.ToList(); | |
| // If there are 0 or 1 elements, no sorting is needed | |
| if (result.Count <= 1) | |
| { | |
| return result; | |
| } | |
| // Check if the list is already sorted by Timestamp | |
| bool alreadySorted = true; | |
| for (int i = 1; i < result.Count; i++) | |
| { | |
| DateTime? prev = result[i - 1].Timestamp; | |
| DateTime? current = result[i].Timestamp; | |
| // Use Nullable.Compare to handle nulls consistently | |
| if (Nullable.Compare(prev, current) > 0) | |
| { | |
| alreadySorted = false; | |
| break; | |
| } | |
| } | |
| if (!alreadySorted) | |
| { | |
| result.Sort((a, b) => Nullable.Compare(a.Timestamp, b.Timestamp)); | |
| } | |
| return result; |
| 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); |
There was a problem hiding this comment.
Using DateTime.Now instead of DateTime.UtcNow for file paths could cause issues in different time zones or during daylight saving time transitions. Two exports made at the "same" local time in different time zones would end up in different folders, and DST transitions could cause confusion.
Consider using DateTime.UtcNow for consistency with the session ID naming pattern used elsewhere (e.g., in MainWindowViewModel line 580).
| @@ -96,22 +96,27 @@ private static void ConfigureServices(IServiceCollection services) | |||
| services.AddDbContextFactory<LogAnalyzerDbContext>(options => | |||
| { | |||
| options.UseSqlite(DbContextConfig.ConnectionString); | |||
There was a problem hiding this comment.
Setting the default query tracking behavior to NoTracking at the DbContext level (line 99) is good for read-heavy operations, but it means write operations will need to explicitly attach entities or use tracking. Ensure that all write operations (e.g., SaveLogsAsync) are tested to verify they work correctly with this global setting.
This is generally a good optimization for this read-heavy application, but it's worth documenting this design decision.
| options.UseSqlite(DbContextConfig.ConnectionString); | |
| options.UseSqlite(DbContextConfig.ConnectionString); | |
| // NOTE: | |
| // This application is predominantly read-heavy, so we intentionally set the | |
| // global query tracking behavior to NoTracking for performance reasons. | |
| // Any write operations that rely on change tracking (for example, methods | |
| // like SaveLogsAsync) must explicitly enable tracking (e.g., via AsTracking()) | |
| // or attach entities to the context before saving. Ensure such write paths | |
| // are covered by tests to verify they behave correctly with this setting. |
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
The Execute method is marked as async void, which is generally appropriate for event handlers and ICommand implementations. However, any exceptions thrown during the execution of _execute() will be swallowed and not observable by the caller. Consider adding try-catch blocks around the await _execute() call to log exceptions or show error messages to the user.
The same applies to the generic AsyncRelayCommand<T> class (line 64).
| 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<TimeSeriesPoint> 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(); | ||
| } |
There was a problem hiding this comment.
The time series grouping logic only supports day-level (>=24 hours) and hour-level (<24 hours) grouping. The groupBy parameter accepts any TimeSpan value, but values like 30 minutes, 2 hours, or 3 days are all treated the same as either 1 hour or 1 day. This could be misleading to callers.
Consider either:
- Documenting this limitation clearly in the method documentation
- Validating the
groupByparameter and throwing an exception for unsupported values - Implementing more granular grouping options
| catch | ||
| { |
There was a problem hiding this comment.
The empty catch block silently ignores failures when deleting a scheduled task. This can hide legitimate errors (e.g., insufficient permissions, corrupted task scheduler database). Consider logging the exception or only catching specific expected error codes.
Recommendation: At minimum, log the exception to Debug output for diagnostic purposes.
| catch | |
| { | |
| catch (Exception ex) | |
| { | |
| Debug.WriteLine($"Error deleting scheduled task '{TaskName}': {ex}"); |
| await Dispatcher.UIThread.InvokeAsync(async () => | ||
| { | ||
| if (UseDatabaseMode && PaginationViewModel != null) | ||
| { | ||
| await PaginationViewModel.LoadLogsAsync().ConfigureAwait(false); | ||
| } | ||
|
|
||
| if (DashboardViewModel != null) | ||
| { | ||
| await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); | ||
| await DashboardViewModel.LoadDashboardDataAsync().ConfigureAwait(false); | ||
| } | ||
| }).ConfigureAwait(false); |
There was a problem hiding this comment.
Calling async methods (LoadSessionsAsync and LoadDashboardDataAsync) from within a UI thread invocation could lead to deadlocks or unexpected behavior. The Dispatcher.UIThread.InvokeAsync executes on the UI thread, and calling .ConfigureAwait(false) inside it doesn't help since you're already on the UI thread.
Consider restructuring this to await the async operations outside the InvokeAsync call, or use Dispatcher.UIThread.Post if you don't need to wait for completion.
| @@ -1001,6 +992,14 @@ | |||
| { | |||
| TextBlock = "Monitoring stopped."; | |||
| IsLoading = false; | |||
|
|
|||
| _statisticsService.InvalidateCache(); | |||
|
|
|||
| if (DashboardViewModel != null) | |||
| { | |||
| await DashboardViewModel.LoadSessionsAsync().ConfigureAwait(false); | |||
| await DashboardViewModel.LoadDashboardDataAsync().ConfigureAwait(false); | |||
| } | |||
| } | |||
| }); | |||
There was a problem hiding this comment.
Same issue as in ClearOldHistoryAsync: calling async methods inside Dispatcher.UIThread.InvokeAsync can cause problems. The nested async lambda (line 978) is executed on the UI thread, and awaiting async operations within it could potentially block the UI thread or cause unexpected behavior.
Consider restructuring to execute the async operations outside the InvokeAsync call.
| DataLabelsPaint = new SolidColorPaint(SKColors.White), | ||
| DataLabelsFormatter = point => $"{d.Name}: {point.Coordinate.PrimaryValue}" | ||
| DataLabelsSize = 12, | ||
| DataLabelsFormatter = point => $"{d.Name} {d.Value}" |
There was a problem hiding this comment.
The DataLabelsFormatter lambda captures the loop variable d, which creates a closure over each iteration's value. This means all pie slices will show the label from their respective data point, not the last one. However, the formatter references d.Name and d.Value directly instead of using the point parameter that's provided to the formatter. This works, but it's not using the LiveCharts API as intended.
Consider using point.Model to access the actual value or rely on the point's coordinate values for more robust charting behavior.
| DataLabelsFormatter = point => $"{d.Name} {d.Value}" | |
| DataLabelsFormatter = point => $"{point.Context.Series.Name} {point.PrimaryValue}" |
This pull request introduces several improvements across the codebase, focusing on database query efficiency, code quality configuration, and infrastructure consistency. The most significant changes include optimizing log statistics queries, adding an asynchronous command helper, and standardizing line endings and editor settings for better cross-platform support.
Database and Repository Improvements:
LogRepositorymethods to use more efficient, database-side grouping and counting for statistics and time series, reducing memory usage and improving performance. This includes changes toGetDetailedStatisticsAsync,GetLogsTimeSeriesAsync, and related methods to avoid loading all logs into memory and to useAsNoTrackingfor read-only queries. [1] [2] [3] [4] [5] [6]LogAnalyzerDbContextby adding composite and named indexes to improve query performance for common filtering scenarios.Infrastructure and Configuration:
.gitattributesfile to enforce consistent line endings for various file types and improve cross-platform development experience..editorconfigto set code quality rules (such as unused parameters and code analysis warnings) to "suggestion" instead of "warning", and added file-specific settings for migrations and YAML files. [1] [2] [3]Dependency Injection and Service Registration:
ConfigureServicesto useAddSingletonorAddTransientas appropriate, and added new services for statistics and export functionality.Command Infrastructure:
AsyncRelayCommandandAsyncRelayCommand<T>classes to simplify asynchronous command execution in the UI layer, improving code readability and user experience.Other Notable Changes:
DeleteSessionAsyncmethod to theILogRepositoryinterface for deleting logs by session.NoTrackingfor improved performance in read scenarios.These changes collectively enhance the application's scalability, maintainability, and developer experience.