diff --git a/CLAUDE.md b/CLAUDE.md index 2e62588..3ee79e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,6 +228,10 @@ public void InitializeFirewallRules() - Squash merge to main - All PRs require code review +## Code Review + +- **Qodo (automated reviewer)**: When Qodo leaves review comments on a PR, always reply to each comment on GitHub explaining what action was taken (fixed, partially fixed, or disagreed with and why). Use `gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies` to post threaded replies. + ## Common Tasks When working on: diff --git a/Daqifi.Desktop.Test/DiskSpace/DiskSpaceMonitorTests.cs b/Daqifi.Desktop.Test/DiskSpace/DiskSpaceMonitorTests.cs new file mode 100644 index 0000000..ad524c2 --- /dev/null +++ b/Daqifi.Desktop.Test/DiskSpace/DiskSpaceMonitorTests.cs @@ -0,0 +1,344 @@ +using Daqifi.Desktop.DiskSpace; + +namespace Daqifi.Desktop.Test.DiskSpace; + +[TestClass] +public class DiskSpaceMonitorTests +{ + #region Constants for readability + private const long MB = 1024 * 1024; + private const string TEST_PATH = @"C:\TestData"; + #endregion + + #region ClassifyLevel Tests + + [TestMethod] + public void ClassifyLevel_Above500MB_PreSession_ReturnsOk() + { + var result = DiskSpaceMonitor.ClassifyLevel(600 * MB, preSession: true); + Assert.AreEqual(DiskSpaceLevel.Ok, result); + } + + [TestMethod] + public void ClassifyLevel_Below500MB_PreSession_ReturnsPreSessionWarning() + { + var result = DiskSpaceMonitor.ClassifyLevel(400 * MB, preSession: true); + Assert.AreEqual(DiskSpaceLevel.PreSessionWarning, result); + } + + [TestMethod] + public void ClassifyLevel_Below500MB_NotPreSession_ReturnsOk() + { + var result = DiskSpaceMonitor.ClassifyLevel(400 * MB, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Ok, result); + } + + [TestMethod] + public void ClassifyLevel_Below100MB_ReturnsWarning() + { + var result = DiskSpaceMonitor.ClassifyLevel(80 * MB, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Warning, result); + } + + [TestMethod] + public void ClassifyLevel_Below100MB_PreSession_ReturnsWarning() + { + var result = DiskSpaceMonitor.ClassifyLevel(80 * MB, preSession: true); + Assert.AreEqual(DiskSpaceLevel.Warning, result); + } + + [TestMethod] + public void ClassifyLevel_Below50MB_ReturnsCritical() + { + var result = DiskSpaceMonitor.ClassifyLevel(30 * MB, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Critical, result); + } + + [TestMethod] + public void ClassifyLevel_Below50MB_PreSession_ReturnsCritical() + { + var result = DiskSpaceMonitor.ClassifyLevel(30 * MB, preSession: true); + Assert.AreEqual(DiskSpaceLevel.Critical, result); + } + + [TestMethod] + public void ClassifyLevel_Exactly500MB_PreSession_ReturnsOk() + { + var result = DiskSpaceMonitor.ClassifyLevel(500 * MB, preSession: true); + Assert.AreEqual(DiskSpaceLevel.Ok, result); + } + + [TestMethod] + public void ClassifyLevel_Exactly100MB_ReturnsOk() + { + var result = DiskSpaceMonitor.ClassifyLevel(100 * MB, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Ok, result); + } + + [TestMethod] + public void ClassifyLevel_Exactly50MB_ReturnsWarning() + { + var result = DiskSpaceMonitor.ClassifyLevel(50 * MB, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Warning, result); + } + + [TestMethod] + public void ClassifyLevel_ZeroBytes_ReturnsCritical() + { + var result = DiskSpaceMonitor.ClassifyLevel(0, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Critical, result); + } + + #endregion + + #region CheckPreLoggingSpace Tests + + [TestMethod] + public void CheckPreLoggingSpace_PlentyOfSpace_ReturnsOk() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + + var result = monitor.CheckPreLoggingSpace(); + + Assert.AreEqual(DiskSpaceLevel.Ok, result.Level); + Assert.AreEqual(1000, result.AvailableMegabytes); + } + + [TestMethod] + public void CheckPreLoggingSpace_Below500MB_ReturnsPreSessionWarning() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 300 * MB); + + var result = monitor.CheckPreLoggingSpace(); + + Assert.AreEqual(DiskSpaceLevel.PreSessionWarning, result.Level); + Assert.AreEqual(300, result.AvailableMegabytes); + } + + [TestMethod] + public void CheckPreLoggingSpace_Below100MB_ReturnsWarning() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 80 * MB); + + var result = monitor.CheckPreLoggingSpace(); + + Assert.AreEqual(DiskSpaceLevel.Warning, result.Level); + } + + [TestMethod] + public void CheckPreLoggingSpace_Below50MB_ReturnsCritical() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 30 * MB); + + var result = monitor.CheckPreLoggingSpace(); + + Assert.AreEqual(DiskSpaceLevel.Critical, result.Level); + } + + [TestMethod] + public void CheckPreLoggingSpace_WhenExceptionThrown_ReturnsOk() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => throw new IOException("Drive not ready")); + + var result = monitor.CheckPreLoggingSpace(); + + Assert.AreEqual(DiskSpaceLevel.Ok, result.Level); + } + + #endregion + + #region StartMonitoring / StopMonitoring Tests + + [TestMethod] + public void StartMonitoring_SetsIsMonitoringTrue() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + + monitor.StartMonitoring(); + + Assert.IsTrue(monitor.IsMonitoring); + monitor.Dispose(); + } + + [TestMethod] + public void StopMonitoring_SetsIsMonitoringFalse() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + monitor.StartMonitoring(); + + monitor.StopMonitoring(); + + Assert.IsFalse(monitor.IsMonitoring); + } + + [TestMethod] + public void StartMonitoring_CalledTwice_DoesNotThrow() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + + monitor.StartMonitoring(); + monitor.StartMonitoring(); + + Assert.IsTrue(monitor.IsMonitoring); + monitor.Dispose(); + } + + [TestMethod] + public void StopMonitoring_WhenNotStarted_DoesNotThrow() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + + monitor.StopMonitoring(); + + Assert.IsFalse(monitor.IsMonitoring); + } + + [TestMethod] + public void Dispose_StopsMonitoring() + { + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + monitor.StartMonitoring(); + + monitor.Dispose(); + + Assert.IsFalse(monitor.IsMonitoring); + } + + #endregion + + #region Event Tests + + [TestMethod] + public void Monitoring_CriticalSpace_RaisesCriticalEvent() + { + var criticalRaised = false; + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 30 * MB); + monitor.CriticalSpaceReached += (_, e) => + { + criticalRaised = true; + Assert.AreEqual(DiskSpaceLevel.Critical, e.Level); + Assert.AreEqual(30, e.AvailableMegabytes); + }; + + monitor.StartMonitoring(); + Thread.Sleep(500); + monitor.Dispose(); + + Assert.IsTrue(criticalRaised, "CriticalSpaceReached event should have been raised"); + } + + [TestMethod] + public void Monitoring_WarningSpace_RaisesWarningEvent() + { + var warningRaised = false; + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 80 * MB); + monitor.LowSpaceWarning += (_, e) => + { + warningRaised = true; + Assert.AreEqual(DiskSpaceLevel.Warning, e.Level); + }; + + monitor.StartMonitoring(); + Thread.Sleep(500); + monitor.Dispose(); + + Assert.IsTrue(warningRaised, "LowSpaceWarning event should have been raised"); + } + + [TestMethod] + public void Monitoring_OkSpace_RaisesNoEvents() + { + var eventRaised = false; + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 1000 * MB); + monitor.LowSpaceWarning += (_, _) => eventRaised = true; + monitor.CriticalSpaceReached += (_, _) => eventRaised = true; + + monitor.StartMonitoring(); + Thread.Sleep(500); + monitor.Dispose(); + + Assert.IsFalse(eventRaised, "No events should be raised when space is sufficient"); + } + + [TestMethod] + public void Monitoring_WarningRaisedOnlyOnce() + { + var warningCount = 0; + var monitor = new DiskSpaceMonitor(TEST_PATH, _ => 80 * MB); + monitor.LowSpaceWarning += (_, _) => Interlocked.Increment(ref warningCount); + + monitor.StartMonitoring(); + Thread.Sleep(500); + monitor.Dispose(); + + Assert.AreEqual(1, warningCount, "Warning should only be raised once per monitoring session"); + } + + [TestMethod] + public void ClassifyLevel_CriticalSkipsWarning() + { + // When space drops directly to critical (below 50 MB), + // ClassifyLevel returns Critical, not Warning — so the + // monitor raises CriticalSpaceReached without LowSpaceWarning. + var level = DiskSpaceMonitor.ClassifyLevel(30 * MB, preSession: false); + Assert.AreEqual(DiskSpaceLevel.Critical, level); + Assert.AreNotEqual(DiskSpaceLevel.Warning, level); + } + + #endregion + + #region DiskSpaceCheckResult Tests + + [TestMethod] + public void DiskSpaceCheckResult_AvailableMegabytes_ConvertsCorrectly() + { + var result = new DiskSpaceCheckResult(512 * MB, DiskSpaceLevel.Ok); + + Assert.AreEqual(512, result.AvailableMegabytes); + Assert.AreEqual(512 * MB, result.AvailableBytes); + } + + #endregion + + #region DiskSpaceEventArgs Tests + + [TestMethod] + public void DiskSpaceEventArgs_AvailableMegabytes_ConvertsCorrectly() + { + var args = new DiskSpaceEventArgs(256 * MB, DiskSpaceLevel.Warning); + + Assert.AreEqual(256, args.AvailableMegabytes); + Assert.AreEqual(256 * MB, args.AvailableBytes); + Assert.AreEqual(DiskSpaceLevel.Warning, args.Level); + } + + #endregion + + #region Constructor Validation Tests + + [TestMethod] + public void Constructor_NullPath_ThrowsArgumentNullException() + { + Assert.ThrowsExactly(() => new DiskSpaceMonitor(null!, _ => 1000 * MB)); + } + + [TestMethod] + public void Constructor_NullFreeSpaceProvider_ThrowsArgumentNullException() + { + Assert.ThrowsExactly(() => new DiskSpaceMonitor(TEST_PATH, null!)); + } + + #endregion + + #region Threshold Constants Tests + + [TestMethod] + public void ThresholdConstants_HaveCorrectValues() + { + Assert.AreEqual(500 * MB, DiskSpaceMonitor.PRE_SESSION_WARNING_BYTES); + Assert.AreEqual(100 * MB, DiskSpaceMonitor.WARNING_THRESHOLD_BYTES); + Assert.AreEqual(50 * MB, DiskSpaceMonitor.CRITICAL_THRESHOLD_BYTES); + } + + #endregion +} diff --git a/Daqifi.Desktop/DiskSpace/DiskSpaceCheckResult.cs b/Daqifi.Desktop/DiskSpace/DiskSpaceCheckResult.cs new file mode 100644 index 0000000..43d2fcc --- /dev/null +++ b/Daqifi.Desktop/DiskSpace/DiskSpaceCheckResult.cs @@ -0,0 +1,28 @@ +namespace Daqifi.Desktop.DiskSpace; + +/// +/// Result of a pre-logging disk space check. +/// +public class DiskSpaceCheckResult +{ + /// + /// Available disk space in bytes. + /// + public long AvailableBytes { get; } + + /// + /// Available disk space in megabytes. + /// + public long AvailableMegabytes => AvailableBytes / (1024 * 1024); + + /// + /// The disk space level determined by the check. + /// + public DiskSpaceLevel Level { get; } + + public DiskSpaceCheckResult(long availableBytes, DiskSpaceLevel level) + { + AvailableBytes = availableBytes; + Level = level; + } +} diff --git a/Daqifi.Desktop/DiskSpace/DiskSpaceEventArgs.cs b/Daqifi.Desktop/DiskSpace/DiskSpaceEventArgs.cs new file mode 100644 index 0000000..eff5c9c --- /dev/null +++ b/Daqifi.Desktop/DiskSpace/DiskSpaceEventArgs.cs @@ -0,0 +1,28 @@ +namespace Daqifi.Desktop.DiskSpace; + +/// +/// Provides data for disk space threshold events. +/// +public class DiskSpaceEventArgs : EventArgs +{ + /// + /// Available disk space in bytes at the time the event was raised. + /// + public long AvailableBytes { get; } + + /// + /// Available disk space in megabytes at the time the event was raised. + /// + public long AvailableMegabytes => AvailableBytes / (1024 * 1024); + + /// + /// The threshold level that was crossed. + /// + public DiskSpaceLevel Level { get; } + + public DiskSpaceEventArgs(long availableBytes, DiskSpaceLevel level) + { + AvailableBytes = availableBytes; + Level = level; + } +} diff --git a/Daqifi.Desktop/DiskSpace/DiskSpaceLevel.cs b/Daqifi.Desktop/DiskSpace/DiskSpaceLevel.cs new file mode 100644 index 0000000..684adb8 --- /dev/null +++ b/Daqifi.Desktop/DiskSpace/DiskSpaceLevel.cs @@ -0,0 +1,27 @@ +namespace Daqifi.Desktop.DiskSpace; + +/// +/// Represents the severity level of a disk space check. +/// +public enum DiskSpaceLevel +{ + /// + /// Sufficient disk space available. + /// + Ok, + + /// + /// Below 500 MB — pre-session warning threshold. + /// + PreSessionWarning, + + /// + /// Below 100 MB — active session warning threshold. + /// + Warning, + + /// + /// Below 50 MB — logging must be stopped immediately. + /// + Critical +} diff --git a/Daqifi.Desktop/DiskSpace/DiskSpaceMonitor.cs b/Daqifi.Desktop/DiskSpace/DiskSpaceMonitor.cs new file mode 100644 index 0000000..4b3133d --- /dev/null +++ b/Daqifi.Desktop/DiskSpace/DiskSpaceMonitor.cs @@ -0,0 +1,196 @@ +using Daqifi.Desktop.Common.Loggers; +using System.IO; + +namespace Daqifi.Desktop.DiskSpace; + +/// +/// Monitors available disk space on the drive containing the DAQiFi data directory +/// and raises events when predefined thresholds are crossed. +/// +public class DiskSpaceMonitor : IDiskSpaceMonitor +{ + #region Constants + /// Pre-session warning: 500 MB. + public const long PRE_SESSION_WARNING_BYTES = 500L * 1024 * 1024; + + /// Active session warning: 100 MB. + public const long WARNING_THRESHOLD_BYTES = 100L * 1024 * 1024; + + /// Hard stop: 50 MB. + public const long CRITICAL_THRESHOLD_BYTES = 50L * 1024 * 1024; + + private const int MONITOR_INTERVAL_MS = 15_000; + #endregion + + #region Private Fields + private readonly AppLogger _appLogger = AppLogger.Instance; + private readonly string _monitoredPath; + private readonly Func _getAvailableFreeSpace; + private readonly object _lock = new(); + private System.Threading.Timer? _timer; + private bool _disposed; + private bool _warningRaised; + #endregion + + #region Events + public event EventHandler? LowSpaceWarning; + public event EventHandler? CriticalSpaceReached; + #endregion + + #region Properties + public bool IsMonitoring => _timer != null; + #endregion + + #region Constructor + /// + /// Creates a new disk space monitor for the specified path. + /// + /// Path on the drive to monitor (typically the data directory). + public DiskSpaceMonitor(string monitoredPath) + : this(monitoredPath, GetAvailableFreeSpaceForPath) + { + } + + /// + /// Creates a new disk space monitor with an injectable free-space provider for testing. + /// + internal DiskSpaceMonitor(string monitoredPath, Func getAvailableFreeSpace) + { + _monitoredPath = monitoredPath ?? throw new ArgumentNullException(nameof(monitoredPath)); + _getAvailableFreeSpace = getAvailableFreeSpace ?? throw new ArgumentNullException(nameof(getAvailableFreeSpace)); + } + #endregion + + #region Public Methods + /// + public DiskSpaceCheckResult CheckPreLoggingSpace() + { + try + { + var available = GetAvailableSpace(); + var level = ClassifyLevel(available, preSession: true); + + _appLogger.Information($"Pre-logging disk space check: {available / (1024 * 1024)} MB available, level={level}"); + + return new DiskSpaceCheckResult(available, level); + } + catch (Exception ex) + { + _appLogger.Error(ex, "Failed to check disk space — assuming OK to avoid blocking logging"); + return new DiskSpaceCheckResult(long.MaxValue, DiskSpaceLevel.Ok); + } + } + + /// + public void StartMonitoring(bool suppressInitialWarning = false) + { + lock (_lock) + { + if (_timer != null) + { + return; + } + + _warningRaised = suppressInitialWarning; + _timer = new System.Threading.Timer(OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(MONITOR_INTERVAL_MS)); + _appLogger.Information("Disk space monitoring started"); + } + } + + /// + public void StopMonitoring() + { + lock (_lock) + { + if (_timer == null) + { + return; + } + + _timer.Dispose(); + _timer = null; + _warningRaised = false; + _appLogger.Information("Disk space monitoring stopped"); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + StopMonitoring(); + _disposed = true; + GC.SuppressFinalize(this); + } + #endregion + + #region Private Methods + private void OnTimerTick(object? state) + { + try + { + var available = GetAvailableSpace(); + var level = ClassifyLevel(available, preSession: false); + + lock (_lock) + { + switch (level) + { + case DiskSpaceLevel.Critical: + // Stop the timer first to prevent duplicate critical events + // before the UI thread can call StopMonitoring() + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + _appLogger.Warning($"Disk space critically low: {available / (1024 * 1024)} MB — triggering hard stop"); + CriticalSpaceReached?.Invoke(this, new DiskSpaceEventArgs(available, DiskSpaceLevel.Critical)); + break; + + case DiskSpaceLevel.Warning when !_warningRaised: + _appLogger.Warning($"Disk space low: {available / (1024 * 1024)} MB"); + _warningRaised = true; + LowSpaceWarning?.Invoke(this, new DiskSpaceEventArgs(available, DiskSpaceLevel.Warning)); + break; + } + } + } + catch (Exception ex) + { + _appLogger.Error(ex, "Error checking disk space during monitoring"); + } + } + + private long GetAvailableSpace() + { + return _getAvailableFreeSpace(_monitoredPath); + } + + internal static DiskSpaceLevel ClassifyLevel(long availableBytes, bool preSession) + { + if (availableBytes < CRITICAL_THRESHOLD_BYTES) + { + return DiskSpaceLevel.Critical; + } + + if (availableBytes < WARNING_THRESHOLD_BYTES) + { + return DiskSpaceLevel.Warning; + } + + if (preSession && availableBytes < PRE_SESSION_WARNING_BYTES) + { + return DiskSpaceLevel.PreSessionWarning; + } + + return DiskSpaceLevel.Ok; + } + + private static long GetAvailableFreeSpaceForPath(string path) + { + var driveInfo = new DriveInfo(Path.GetPathRoot(path)!); + return driveInfo.AvailableFreeSpace; + } + #endregion +} diff --git a/Daqifi.Desktop/DiskSpace/IDiskSpaceMonitor.cs b/Daqifi.Desktop/DiskSpace/IDiskSpaceMonitor.cs new file mode 100644 index 0000000..26fd4bb --- /dev/null +++ b/Daqifi.Desktop/DiskSpace/IDiskSpaceMonitor.cs @@ -0,0 +1,43 @@ +namespace Daqifi.Desktop.DiskSpace; + +/// +/// Monitors available disk space and raises events when thresholds are crossed. +/// +public interface IDiskSpaceMonitor : IDisposable +{ + /// + /// Raised when available disk space drops below the warning threshold (100 MB). + /// + event EventHandler LowSpaceWarning; + + /// + /// Raised when available disk space drops below the critical threshold (50 MB), + /// indicating logging must be stopped immediately. + /// + event EventHandler CriticalSpaceReached; + + /// + /// Checks whether there is sufficient disk space to begin a logging session. + /// + /// A result indicating the space level and available bytes. + DiskSpaceCheckResult CheckPreLoggingSpace(); + + /// + /// Starts periodic monitoring of disk space during an active logging session. + /// + /// + /// When true, suppresses the first warning-level notification (e.g., because a pre-session + /// warning was already shown to the user). + /// + void StartMonitoring(bool suppressInitialWarning = false); + + /// + /// Stops periodic disk space monitoring. + /// + void StopMonitoring(); + + /// + /// Whether monitoring is currently active. + /// + bool IsMonitoring { get; } +} diff --git a/Daqifi.Desktop/MainWindow.xaml.cs b/Daqifi.Desktop/MainWindow.xaml.cs index 2b04522..3a9121c 100644 --- a/Daqifi.Desktop/MainWindow.xaml.cs +++ b/Daqifi.Desktop/MainWindow.xaml.cs @@ -23,6 +23,11 @@ public MainWindow() Closing += (sender, e) => { + if (DataContext is DaqifiViewModel viewModel) + { + viewModel.DisposeDiskSpaceMonitor(); + } + if (HostCommands.ShutdownCommand.CanExecute(e)) { HostCommands.ShutdownCommand.Execute(e); diff --git a/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs b/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs index ed8fce8..5f392fd 100644 --- a/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs +++ b/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs @@ -3,6 +3,7 @@ using Daqifi.Desktop.Configuration; using Daqifi.Desktop.Device; using Daqifi.Desktop.DialogService; +using Daqifi.Desktop.DiskSpace; using Daqifi.Desktop.Helpers; using Daqifi.Desktop.Logger; using Daqifi.Desktop.Loggers; @@ -126,6 +127,7 @@ public partial class DaqifiViewModel : ObservableObject private bool _isLogToDeviceMode; private SdCardLogFormat _selectedSdCardLogFormat = SdCardLogFormat.Protobuf; private IStreamingDevice? _deviceBeingUpdated; + private IDiskSpaceMonitor? _diskSpaceMonitor; #endregion #region Properties @@ -163,10 +165,39 @@ public bool IsLogging get => _isLogging; set { + var preSessionWarningShown = false; + if (value && _diskSpaceMonitor != null) + { + var check = _diskSpaceMonitor.CheckPreLoggingSpace(); + if (check.Level == DiskSpaceLevel.Critical) + { + // Notify bindings so TwoWay toggle reverts to false + OnPropertyChanged(nameof(IsLogging)); + _ = ShowDiskSpaceMessage( + "Cannot Start Logging", + $"Only {check.AvailableMegabytes} MB of disk space remaining. " + + "Logging cannot start because the disk is critically low.\n\n" + + "Please free disk space by deleting old logging sessions or removing other files."); + return; + } + + if (check.Level == DiskSpaceLevel.PreSessionWarning || check.Level == DiskSpaceLevel.Warning) + { + preSessionWarningShown = true; + _ = ShowDiskSpaceMessage( + "Low Disk Space Warning", + $"Only {check.AvailableMegabytes} MB of disk space remaining. " + + "Logging may be stopped automatically if space runs out.\n\n" + + "Consider freeing disk space by deleting old logging sessions or removing other files."); + } + } + _isLogging = value; LoggingManager.Instance.Active = value; if (_isLogging) { + _diskSpaceMonitor?.StartMonitoring(suppressInitialWarning: preSessionWarningShown); + foreach (var device in ConnectedDevices) { if (device.Mode == DeviceMode.StreamToApp) @@ -181,6 +212,8 @@ public bool IsLogging } else { + _diskSpaceMonitor?.StopMonitoring(); + foreach (var device in ConnectedDevices) { if (device.Mode == DeviceMode.StreamToApp) @@ -451,6 +484,11 @@ public DaqifiViewModel( SummaryLogger = new SummaryLogger(); LoggingManager.Instance.AddLogger(SummaryLogger); + // Disk space monitoring + _diskSpaceMonitor = new DiskSpaceMonitor(App.DaqifiDataDirectory); + _diskSpaceMonitor.LowSpaceWarning += OnDiskSpaceLowWarning; + _diskSpaceMonitor.CriticalSpaceReached += OnDiskSpaceCritical; + if (LoggingManager.Instance.LoggingSessions == null || !LoggingManager.Instance.LoggingSessions.Any()) { LoggingManager.Instance.LoggingSessions = LoggingManager.Instance.LoadPersistedLoggingSessions(); @@ -2220,4 +2258,69 @@ private void OnDebugDataReceived(DebugDataModel debugData) } #endregion + + #region Disk Space Monitoring + + /// + /// Disposes disk space monitoring resources. Call on application shutdown. + /// + public void DisposeDiskSpaceMonitor() + { + if (_diskSpaceMonitor == null) + { + return; + } + + _diskSpaceMonitor.LowSpaceWarning -= OnDiskSpaceLowWarning; + _diskSpaceMonitor.CriticalSpaceReached -= OnDiskSpaceCritical; + _diskSpaceMonitor.Dispose(); + _diskSpaceMonitor = null; + } + + private void OnDiskSpaceLowWarning(object? sender, DiskSpaceEventArgs e) + { + // BeginInvoke (async) to avoid blocking the timer thread + Application.Current?.Dispatcher?.BeginInvoke(() => + { + _ = ShowDiskSpaceMessage( + "Low Disk Space Warning", + $"Only {e.AvailableMegabytes} MB of disk space remaining. " + + "Logging will be stopped automatically if space drops below 50 MB.\n\n" + + "Consider freeing disk space by deleting old logging sessions or removing other files."); + }); + } + + private void OnDiskSpaceCritical(object? sender, DiskSpaceEventArgs e) + { + // BeginInvoke (async) to avoid blocking the timer thread + Application.Current?.Dispatcher?.BeginInvoke(() => + { + _appLogger.Warning($"Disk space critical ({e.AvailableMegabytes} MB) — automatically stopping logging"); + IsLogging = false; + OnPropertyChanged(nameof(IsLogging)); + + _ = ShowDiskSpaceMessage( + "Logging Stopped — Disk Space Critical", + $"Logging was automatically stopped because disk space dropped to {e.AvailableMegabytes} MB.\n\n" + + "To prevent system instability, logging has been halted. " + + "Please free disk space by deleting old logging sessions or removing other files before resuming."); + }); + } + + private async Task ShowDiskSpaceMessage(string title, string message) + { + try + { + if (Application.Current?.MainWindow is MetroWindow metroWindow) + { + await metroWindow.ShowMessageAsync(title, message, MessageDialogStyle.Affirmative, metroWindow.MetroDialogOptions); + } + } + catch (Exception ex) + { + _appLogger.Error(ex, "Failed to show disk space warning dialog"); + } + } + + #endregion }