diff --git a/Source/NETworkManager.Profiles/ProfileManager.cs b/Source/NETworkManager.Profiles/ProfileManager.cs index 71e02e49e3..084b831f35 100644 --- a/Source/NETworkManager.Profiles/ProfileManager.cs +++ b/Source/NETworkManager.Profiles/ProfileManager.cs @@ -1,4 +1,6 @@ -using NETworkManager.Settings; +using log4net; +using NETworkManager.Models.Network; +using NETworkManager.Settings; using NETworkManager.Utilities; using System; using System.Collections.Generic; @@ -13,19 +15,8 @@ namespace NETworkManager.Profiles; public static class ProfileManager { - #region Constructor - - /// - /// Static constructor. Load all profile files on startup. - /// - static ProfileManager() - { - LoadProfileFiles(); - } - - #endregion - #region Variables + private static readonly ILog Log = LogManager.GetLogger(typeof(ProfileManager)); /// /// Profiles directory name. @@ -84,6 +75,18 @@ private set #endregion + #region Constructor + + /// + /// Static constructor. Load all profile files on startup. + /// + static ProfileManager() + { + LoadProfileFiles(); + } + + #endregion + #region Events /// @@ -202,7 +205,7 @@ public static void CreateEmptyProfileFile(string profileName) Directory.CreateDirectory(GetProfilesFolderLocation()); - SerializeToFile(profileFileInfo.Path, new List()); + SerializeToFile(profileFileInfo.Path, []); ProfileFiles.Add(profileFileInfo); } @@ -219,7 +222,6 @@ public static void RenameProfileFile(ProfileFileInfo profileFileInfo, string new if (LoadedProfileFile != null && LoadedProfileFile.Equals(profileFileInfo)) { Save(); - switchProfile = true; } @@ -472,7 +474,12 @@ private static void Load(ProfileFileInfo profileFileInfo) public static void Save() { if (LoadedProfileFile == null) + { + Log.Warn("Cannot save profiles because no profile file is loaded. The profile file may be encrypted and not yet unlocked."); + return; + } + Directory.CreateDirectory(GetProfilesFolderLocation()); diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 48843d0237..af7ea9117d 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -46,6 +46,9 @@ public static class GlobalStaticConfiguration public static string ZipFileExtensionFilter => "ZIP Archive (*.zip)|*.zip"; public static string XmlFileExtensionFilter => "XML-File (*.xml)|*.xml"; + // Backup settings + public static int Backup_MaximumNumberOfBackups => 10; + #endregion #region Default settings diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index d3d5d4c83f..4a41e94680 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -109,6 +109,27 @@ public string Version } } + /// + /// Private field for the property. + /// + private DateTime _lastBackup = DateTime.MinValue; + + /// + /// Stores the date of the last backup of the settings file. + /// + public DateTime LastBackup + { + get => _lastBackup; + set + { + if (value == _lastBackup) + return; + + _lastBackup = value; + OnPropertyChanged(); + } + } + #region General // General @@ -1216,7 +1237,7 @@ public ExportFileType IPScanner_ExportFileType #region Port Scanner - private ObservableCollection _portScanner_HostHistory = new(); + private ObservableCollection _portScanner_HostHistory = []; public ObservableCollection PortScanner_HostHistory { @@ -1231,7 +1252,7 @@ public ObservableCollection PortScanner_HostHistory } } - private ObservableCollection _portScanner_PortHistory = new(); + private ObservableCollection _portScanner_PortHistory = []; public ObservableCollection PortScanner_PortHistory { @@ -1246,7 +1267,7 @@ public ObservableCollection PortScanner_PortHistory } } - private ObservableCollection _portScanner_PortProfiles = new(); + private ObservableCollection _portScanner_PortProfiles = []; public ObservableCollection PortScanner_PortProfiles { @@ -1400,7 +1421,7 @@ public ExportFileType PortScanner_ExportFileType #region Ping Monitor - private ObservableCollection _pingMonitor_HostHistory = new(); + private ObservableCollection _pingMonitor_HostHistory = []; public ObservableCollection PingMonitor_HostHistory { @@ -1569,7 +1590,7 @@ public double PingMonitor_ProfileWidth #region Traceroute - private ObservableCollection _traceroute_HostHistory = new(); + private ObservableCollection _traceroute_HostHistory = []; public ObservableCollection Traceroute_HostHistory { @@ -1999,7 +2020,7 @@ public ExportFileType DNSLookup_ExportFileType #region Remote Desktop - private ObservableCollection _remoteDesktop_HostHistory = new(); + private ObservableCollection _remoteDesktop_HostHistory = []; public ObservableCollection RemoteDesktop_HostHistory { @@ -2579,7 +2600,7 @@ public double RemoteDesktop_ProfileWidth #region PowerShell - private ObservableCollection _powerShell_HostHistory = new(); + private ObservableCollection _powerShell_HostHistory = []; public ObservableCollection PowerShell_HostHistory { @@ -2689,7 +2710,7 @@ public double PowerShell_ProfileWidth #region PuTTY - private ObservableCollection _puTTY_HostHistory = new(); + private ObservableCollection _puTTY_HostHistory = []; public ObservableCollection PuTTY_HostHistory { @@ -2839,7 +2860,7 @@ public string PuTTY_AdditionalCommandLine } } - private ObservableCollection _puTTY_SerialLineHistory = new(); + private ObservableCollection _puTTY_SerialLineHistory = []; public ObservableCollection PuTTY_SerialLineHistory { @@ -2854,7 +2875,7 @@ public ObservableCollection PuTTY_SerialLineHistory } } - private ObservableCollection _puTTY_PortHistory = new(); + private ObservableCollection _puTTY_PortHistory = []; public ObservableCollection PuTTY_PortHistory { @@ -2869,7 +2890,7 @@ public ObservableCollection PuTTY_PortHistory } } - private ObservableCollection _puTTY_BaudHistory = new(); + private ObservableCollection _puTTY_BaudHistory = []; public ObservableCollection PuTTY_BaudHistory { @@ -2884,7 +2905,7 @@ public ObservableCollection PuTTY_BaudHistory } } - private ObservableCollection _puTTY_UsernameHistory = new(); + private ObservableCollection _puTTY_UsernameHistory = []; public ObservableCollection PuTTY_UsernameHistory { @@ -2899,7 +2920,7 @@ public ObservableCollection PuTTY_UsernameHistory } } - private ObservableCollection _puTTY_PrivateKeyFileHistory = new(); + private ObservableCollection _puTTY_PrivateKeyFileHistory = []; public ObservableCollection PuTTY_PrivateKeyFileHistory { @@ -2914,7 +2935,7 @@ public ObservableCollection PuTTY_PrivateKeyFileHistory } } - private ObservableCollection _puTTY_ProfileHistory = new(); + private ObservableCollection _puTTY_ProfileHistory = []; public ObservableCollection PuTTY_ProfileHistory { @@ -3069,7 +3090,7 @@ public int PuTTY_RawPort #region TigerVNC - private ObservableCollection _tigerVNC_HostHistory = new(); + private ObservableCollection _tigerVNC_HostHistory = []; public ObservableCollection TigerVNC_HostHistory { @@ -3084,7 +3105,7 @@ public ObservableCollection TigerVNC_HostHistory } } - private ObservableCollection _tigerVNC_PortHistory = new(); + private ObservableCollection _tigerVNC_PortHistory = []; public ObservableCollection TigerVNC_PortHistory { @@ -3163,7 +3184,7 @@ public int TigerVNC_Port #region Web Console - private ObservableCollection _webConsole_UrlHistory = new(); + private ObservableCollection _webConsole_UrlHistory = []; public ObservableCollection WebConsole_UrlHistory { @@ -3257,7 +3278,7 @@ public bool WebConsole_IsPasswordSaveEnabled #region SNMP - private ObservableCollection _snmp_HostHistory = new(); + private ObservableCollection _snmp_HostHistory = []; public ObservableCollection SNMP_HostHistory { @@ -3272,7 +3293,7 @@ public ObservableCollection SNMP_HostHistory } } - private ObservableCollection _snmp_OidHistory = new(); + private ObservableCollection _snmp_OidHistory = []; public ObservableCollection SNMP_OidHistory { @@ -3287,7 +3308,7 @@ public ObservableCollection SNMP_OidHistory } } - private ObservableCollection _snmp_OidProfiles = new(); + private ObservableCollection _snmp_OidProfiles = []; public ObservableCollection SNMP_OidProfiles { @@ -3681,7 +3702,7 @@ public int WakeOnLAN_Port } } - private ObservableCollection _wakeOnLan_MACAddressHistory = new(); + private ObservableCollection _wakeOnLan_MACAddressHistory = []; public ObservableCollection WakeOnLan_MACAddressHistory { @@ -3696,7 +3717,7 @@ public ObservableCollection WakeOnLan_MACAddressHistory } } - private ObservableCollection _wakeOnLan_BroadcastHistory = new(); + private ObservableCollection _wakeOnLan_BroadcastHistory = []; public ObservableCollection WakeOnLan_BroadcastHistory { @@ -3745,7 +3766,7 @@ public double WakeOnLAN_ProfileWidth #region Whois - private ObservableCollection _whois_DomainHistory = new(); + private ObservableCollection _whois_DomainHistory = []; public ObservableCollection Whois_DomainHistory { @@ -3824,7 +3845,7 @@ public ExportFileType Whois_ExportFileType #region IP Geolocation - private ObservableCollection _ipGeolocation_HostHistory = new(); + private ObservableCollection _ipGeolocation_HostHistory = []; public ObservableCollection IPGeolocation_HostHistory { @@ -3905,7 +3926,7 @@ public ExportFileType IPGeolocation_ExportFileType #region Calculator - private ObservableCollection _subnetCalculator_Calculator_SubnetHistory = new(); + private ObservableCollection _subnetCalculator_Calculator_SubnetHistory = []; public ObservableCollection SubnetCalculator_Calculator_SubnetHistory { @@ -3924,7 +3945,7 @@ public ObservableCollection SubnetCalculator_Calculator_SubnetHistory #region Subnetting - private ObservableCollection _subnetCalculator_Subnetting_SubnetHistory = new(); + private ObservableCollection _subnetCalculator_Subnetting_SubnetHistory = []; public ObservableCollection SubnetCalculator_Subnetting_SubnetHistory { @@ -3939,7 +3960,7 @@ public ObservableCollection SubnetCalculator_Subnetting_SubnetHistory } } - private ObservableCollection _subnetCalculator_Subnetting_NewSubnetmaskHistory = new(); + private ObservableCollection _subnetCalculator_Subnetting_NewSubnetmaskHistory = []; public ObservableCollection SubnetCalculator_Subnetting_NewSubnetmaskHistory { @@ -3989,7 +4010,7 @@ public ExportFileType SubnetCalculator_Subnetting_ExportFileType #region WideSubnet - private ObservableCollection _subnetCalculator_WideSubnet_Subnet1 = new(); + private ObservableCollection _subnetCalculator_WideSubnet_Subnet1 = []; public ObservableCollection SubnetCalculator_WideSubnet_Subnet1 { @@ -4004,7 +4025,7 @@ public ObservableCollection SubnetCalculator_WideSubnet_Subnet1 } } - private ObservableCollection _subnetCalculator_WideSubnet_Subnet2 = new(); + private ObservableCollection _subnetCalculator_WideSubnet_Subnet2 = []; public ObservableCollection SubnetCalculator_WideSubnet_Subnet2 { @@ -4025,7 +4046,7 @@ public ObservableCollection SubnetCalculator_WideSubnet_Subnet2 #region Bit Calculator - private ObservableCollection _bitCalculator_InputHistory = new(); + private ObservableCollection _bitCalculator_InputHistory = []; public ObservableCollection BitCalculator_InputHistory { diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 1ce2e093d4..61f95802ac 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -178,17 +178,12 @@ public static void Load() Save(); // Create a backup of the legacy XML file and delete the original - Directory.CreateDirectory(GetSettingsBackupFolderLocation()); - - var backupFilePath = Path.Combine(GetSettingsBackupFolderLocation(), - $"{TimestampHelper.GetTimestamp()}_{GetLegacySettingsFileName()}"); - - File.Copy(legacyFilePath, backupFilePath, true); + Backup(legacyFilePath, + GetSettingsBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(GetLegacySettingsFileName())); File.Delete(legacyFilePath); - Log.Info($"Legacy XML settings file backed up to: {backupFilePath}"); - Log.Info("Settings migration from XML to JSON completed successfully."); return; @@ -237,6 +232,9 @@ public static void Save() // Create the directory if it does not exist Directory.CreateDirectory(GetSettingsFolderLocation()); + // Create backup before modifying + CreateDailyBackupIfNeeded(); + // Serialize the settings to a file SerializeToFile(GetSettingsFilePath()); @@ -258,15 +256,93 @@ private static void SerializeToFile(string filePath) #endregion #region Backup - /* - private static void Backup() + /// + /// Creates a backup of the settings file if a backup has not already been created for the current day. + /// + /// This method checks whether a backup for the current date exists and, if not, creates a new + /// backup of the settings file. It also removes old backups according to the configured maximum number of backups. + /// If the settings file does not exist, no backup is created and a warning is logged. This method is intended to be + /// called as part of a daily maintenance routine. + private static void CreateDailyBackupIfNeeded() { - Log.Info("Creating settings backup..."); + var currentDate = DateTime.Now.Date; + if (Current.LastBackup < currentDate) + { + // Check if settings file exists + if (!File.Exists(GetSettingsFilePath())) + { + Log.Warn("Settings file does not exist yet. Skipping backup creation..."); + return; + } + + // Create backup + Backup(GetSettingsFilePath(), + GetSettingsBackupFolderLocation(), + TimestampHelper.GetTimestampFilename(GetSettingsFileName())); + + // Cleanup old backups + CleanupBackups(GetSettingsBackupFolderLocation(), + GetSettingsFileName(), + GlobalStaticConfiguration.Backup_MaximumNumberOfBackups); + + Current.LastBackup = currentDate; + } + } + + /// + /// Deletes older backup files in the specified folder to ensure that only the most recent backups, up to the + /// specified maximum, are retained. + /// + /// This method removes the oldest backup files first, keeping only the most recent backups as + /// determined by the timestamp in the filename. It is intended to prevent excessive accumulation of backup files and manage + /// disk space usage. + /// The full path to the directory containing the backup files to be managed. Cannot be null or empty. + /// The file name pattern used to identify backup files for cleanup. + /// The maximum number of backup files to retain. Must be greater than zero. + private static void CleanupBackups(string backupFolderPath, string settingsFileName, int maxBackupFiles) + { + // Get all backup files sorted by timestamp (newest first) + var backupFiles = Directory.GetFiles(backupFolderPath) + .Where(f => (f.EndsWith(settingsFileName) || f.EndsWith(GetLegacySettingsFileName())) && TimestampHelper.IsTimestampedFilename(Path.GetFileName(f))) + .OrderByDescending(f => TimestampHelper.ExtractTimestampFromFilename(Path.GetFileName(f))) + .ToList(); + + if (backupFiles.Count > maxBackupFiles) + Log.Info($"Cleaning up old backup files... Found {backupFiles.Count} backups, keeping the most recent {maxBackupFiles}."); + + // Delete oldest backups until the maximum number is reached + while (backupFiles.Count > maxBackupFiles) + { + var fileToDelete = backupFiles.Last(); + + File.Delete(fileToDelete); + + backupFiles.RemoveAt(backupFiles.Count - 1); + + Log.Info($"Backup deleted: {fileToDelete}"); + } + } + + /// + /// Creates a backup of the specified settings file in the given backup folder with the provided backup file name. + /// + /// The full path to the settings file to back up. Cannot be null or empty. + /// The directory path where the backup file will be stored. If the directory does not exist, it will be created. + /// The name to use for the backup file within the backup folder. Cannot be null or empty. + private static void Backup(string settingsFilePath, string backupFolderPath, string backupFileName) + { // Create the backup directory if it does not exist - Directory.CreateDirectory(GetSettingsBackupFolderLocation()); + Directory.CreateDirectory(backupFolderPath); + + // Create the backup file path + var backupFilePath = Path.Combine(backupFolderPath, backupFileName); + + // Copy the current settings file to the backup location + File.Copy(settingsFilePath, backupFilePath, true); + + Log.Info($"Backup created: {backupFilePath}"); } - */ #endregion diff --git a/Source/NETworkManager.Utilities/TimestampHelper.cs b/Source/NETworkManager.Utilities/TimestampHelper.cs index 624f082b05..0ed4b5b83b 100644 --- a/Source/NETworkManager.Utilities/TimestampHelper.cs +++ b/Source/NETworkManager.Utilities/TimestampHelper.cs @@ -1,4 +1,6 @@ using System; +using System.Globalization; +using System.IO; namespace NETworkManager.Utilities; @@ -8,4 +10,48 @@ public static string GetTimestamp() { return DateTime.Now.ToString("yyyyMMddHHmmss"); } + + /// + /// Generates a filename by prefixing the specified filename with a timestamp string. + /// + /// The original filename to be prefixed with a timestamp. Cannot be null or empty. + /// A string containing the timestamp followed by an underscore and the original filename. + public static string GetTimestampFilename(string fileName) + { + return $"{GetTimestamp()}_{fileName}"; + } + + /// + /// Determines whether the specified file name begins with a valid timestamp in the format "yyyyMMddHHmmss". + /// + /// This method checks only the first 14 characters of the file name for a valid timestamp and + /// does not validate the remainder of the file name. + /// The file name to evaluate. The file name is expected to start with a 14-digit timestamp followed by an + /// underscore and at least one additional character. + /// true if the file name starts with a valid timestamp in the format "yyyyMMddHHmmss"; otherwise, false. + public static bool IsTimestampedFilename(string fileName) + { + // Ensure the filename is long enough to contain a timestamp, an underscore, and at least one character after it + if (fileName.Length < 16) + return false; + + var timestampString = fileName.Substring(0, 14); + + return DateTime.TryParseExact(timestampString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + } + + /// + /// Extracts the timestamp from a filename that starts with a timestamp prefix. + /// + /// Filenames are expected to start with yyyyMMddHHmmss_* format (14 characters). + /// This method extracts the timestamp portion and parses it as a DateTime. + /// The full path to the file or just the filename. + /// The timestamp extracted from the filename. + public static DateTime ExtractTimestampFromFilename(string fileName) + { + // Extract the timestamp prefix (yyyyMMddHHmmss format, 14 characters) + var timestampString = fileName.Substring(0, 14); + + return DateTime.ParseExact(timestampString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture); + } } \ No newline at end of file diff --git a/Source/NETworkManager/App.xaml.cs b/Source/NETworkManager/App.xaml.cs index ace2a1267f..b3e4cd3f1f 100644 --- a/Source/NETworkManager/App.xaml.cs +++ b/Source/NETworkManager/App.xaml.cs @@ -94,7 +94,7 @@ by BornToBeRoot catch (InvalidOperationException ex) { Log.Error("Could not load application settings!", ex); - + HandleCorruptedSettingsFile(); } catch (JsonException ex) diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index 12918d0ba7..6cc3c66e37 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -760,7 +760,7 @@ private void RemoveHostByGuid(Guid hostId) private void AddHostToHistory(string host) { // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.PingMonitor_HostHistory.ToList(), host, + var list = ListHelper.Modify([.. SettingsManager.Current.PingMonitor_HostHistory], host, SettingsManager.Current.General_HistoryListEntries); // Clear the old items @@ -768,7 +768,7 @@ private void AddHostToHistory(string host) OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared // Fill with the new items - list.ForEach(x => SettingsManager.Current.PingMonitor_HostHistory.Add(x)); + list.ForEach(SettingsManager.Current.PingMonitor_HostHistory.Add); } private void SetIsExpandedForAllProfileGroups(bool isExpanded) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 417fc79f9c..ba441a511c 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -54,6 +54,7 @@ Release date: **xx.xx.2025** **Settings** - Settings format migrated from `XML` to `JSON`. The settings file will be automatically converted on first start after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282) +- Create a daily backup of the settings file before saving changes. Up to `10` backup files are kept in the `Backups` subfolder of the settings directory. [#3283](https://github.com/BornToBeRoot/NETworkManager/pull/3283) **DNS Lookup** diff --git a/Website/docs/settings/settings.md b/Website/docs/settings/settings.md index 5bf0b9e593..f772085232 100644 --- a/Website/docs/settings/settings.md +++ b/Website/docs/settings/settings.md @@ -18,9 +18,22 @@ Folder where the application settings are stored. | Portable | `\Settings` | :::note -It is recommended to backup the above files on a regular basis. -To restore the settings, close the application and copy the files from the backup to the above location. +**Recommendation** +It is strongly recommended to regularly back up your settings files. + +**Automatic backups** +NETworkManager automatically creates a backup of the settings files before applying any changes. +- Location: `Settings\Backups` subfolder (relative to the main configuration directory) +- Naming: timestamped (e.g. `yyyyMMddHHmmss_Settings.json`) +- Frequency: **once per day** at most (even if multiple changes occur) +- Retention: keeps the **10 most recent backups** + +**Restoring settings** +1. Completely close NETworkManager +2. Locate the desired backup in `Settings\Backups` +3. Copy the file(s) back to the original configuration folder (overwriting existing files) +4. Restart the application :::