maksit-lto-backup/src/MaksIT.LTO.Backup.UI/ViewModels/MainViewModel.Settings.cs
2026-09-06 15:33:46 +02:00

559 lines
19 KiB
C#

using CommunityToolkit.Mvvm.Input;
using MaksIT.LTO.Backup.Shared;
using MaksIT.LTO.Backup.Shared.Models;
namespace MaksIT.LTO.Backup.UI.ViewModels;
public partial class MainViewModel {
private void RefreshBackupLists() {
var selectedName = SelectedBackup?.Name ?? SelectedSettingsBackup?.Name;
BackupJobs.Clear();
foreach (var backup in _backupOrchestrator.Backups)
BackupJobs.Add(backup);
if (BackupJobs.Count == 0) {
SelectedBackup = null;
SelectedSettingsBackup = null;
return;
}
var match = BackupJobs.FirstOrDefault(item => string.Equals(item.Name, selectedName, StringComparison.OrdinalIgnoreCase));
SelectedBackup = match ?? BackupJobs[0];
SelectedSettingsBackup = SelectedBackup;
UpdateJobSummary(SelectedBackup);
}
private void UpdateJobSummary(BackupItem? backup) {
if (backup is null) {
JobSummaryText = "Select a backup job to see source, destination, LTO generation, and barcode.";
return;
}
JobSummaryText = $"{backup.LTOGen} · barcode {backup.Barcode} · source {GetFolderPath(backup.Source)} → destination {GetFolderPath(backup.Destination)}";
}
private void ApplySelectedLibraryDrive(LoadedCartridgeSnapshot selected) {
_backupOrchestrator.SelectedLibraryDriveSlot = selected.DriveSlot;
_loadingDriveSelection = true;
try {
SelectedOccupiedDrive = OccupiedLibraryDrives.FirstOrDefault(item => item.DriveSlot == selected.DriveSlot) ?? selected;
}
finally {
_loadingDriveSelection = false;
}
RefreshLoadedCartridgeSummaries();
}
private void RefreshLoadedCartridgeSummaries() {
var loaded = _backupOrchestrator.GetLoadedCartridge();
var isTapeLibrary = _configurationFileService.Current.IsTapeLibrary;
IsCartridgeLoaded = loaded.IsLoaded;
BackupLoadedSummaryText = loaded.IsLoaded ? (loaded.Summary ?? "Cartridge loaded.") : "No cartridge loaded.";
BackupLoadedHintText = loaded.Hint ?? (loaded.IsLoaded
? "Ready for backup/restore/erase on the selected job."
: "Load media first (Drive tab for standalone, or Library tab move into a drive bay).");
if (!isTapeLibrary)
return;
LibraryLoadedSummaryText = loaded.IsLoaded ? (loaded.Summary ?? "Cartridge in drive.") : "No cartridge in a drive bay.";
LibraryLoadedHintText = loaded.Hint ?? "Move a cartridge from a storage slot into a drive bay, then select the drive if more than one is loaded.";
if (loaded.IsLoaded && !string.IsNullOrWhiteSpace(loaded.Barcode))
LibraryBarcodeText = loaded.Barcode;
}
private void UpdateLoadedCartridgeUi() {
PopulateActiveDriveCombos();
RefreshLoadedCartridgeSummaries();
}
private void PopulateActiveDriveCombos() {
if (!_configurationFileService.Current.IsTapeLibrary) {
OccupiedLibraryDrives.Clear();
SelectedOccupiedDrive = null;
return;
}
var occupied = _backupOrchestrator.GetOccupiedLibraryDrives();
var previousSlot = _backupOrchestrator.SelectedLibraryDriveSlot ?? SelectedOccupiedDrive?.DriveSlot;
_loadingDriveSelection = true;
try {
OccupiedLibraryDrives.Clear();
foreach (var drive in occupied)
OccupiedLibraryDrives.Add(drive);
LoadedCartridgeSnapshot? selected = null;
if (previousSlot.HasValue)
selected = occupied.FirstOrDefault(drive => drive.DriveSlot == previousSlot.Value);
selected ??= occupied.Count == 1 ? occupied[0] : null;
SelectedOccupiedDrive = selected;
_backupOrchestrator.SelectedLibraryDriveSlot = selected?.DriveSlot;
}
finally {
_loadingDriveSelection = false;
}
}
private void LoadSettingsIntoUi() {
_loadingSettings = true;
try {
var current = _configurationFileService.Current;
DeviceMode = MatchOption(DeviceModeOptions, current.DeviceMode) ?? current.DeviceMode;
SelectedTopology = MatchOption(TopologyOptions, current.Topology) ?? current.Topology;
TapePathText = current.TapePath;
LibraryPathText = current.LibraryPath;
WriteDelayText = current.WriteDelay.ToString();
FailFastChecksum = current.FailFastOnChecksumMismatch;
EmulatorDataDirText = current.Emulator.DataDirectory;
EmulatorSlotCountText = current.Emulator.Library.SlotCount.ToString();
EmulatorDriveCountText = current.Emulator.Library.DriveCount.ToString();
EmulatorIePortCountText = current.Emulator.Library.IePortCount.ToString();
ServiceNameText = current.Service.ServiceName;
ServiceDescriptionText = current.Service.Description;
ServiceExePathSettingText = current.Service.ExecutablePath;
LoadSelectedBackupIntoUi(SelectedSettingsBackup);
RefreshAggregationLists();
UpdateTopologyUi();
}
finally {
_loadingSettings = false;
}
}
private void UpdateTopologyUi() {
OnPropertyChanged(nameof(IsTapeLibraryUi));
OnPropertyChanged(nameof(IsStandaloneDriveUi));
OnPropertyChanged(nameof(IsSavedTapeLibrary));
if (IsTapeLibraryUi) {
RefreshAvailableTapeBarcodes();
RefreshAggregationLists();
}
UpdateLoadedCartridgeUi();
}
private void LoadSelectedBackupIntoUi(BackupItem? backup) {
if (backup is null) {
BackupNameText = string.Empty;
BackupBarcodeText = string.Empty;
BackupDisabled = false;
BackupLtoGen = "LTO5";
BackupWriteMode = BackupWriteModes.Overwrite.Name;
BackupSourcePathText = string.Empty;
BackupDestinationPathText = string.Empty;
TapeRows.Clear();
RefreshAvailableTapeBarcodes();
return;
}
BackupNameText = backup.Name;
BackupBarcodeText = backup.Barcode;
BackupDisabled = backup.Disabled;
BackupLtoGen = MatchOption(LtoGenerationOptions, backup.LTOGen) ?? backup.LTOGen;
BackupWriteMode = MatchOption(WriteModeOptions, string.IsNullOrWhiteSpace(backup.WriteMode) ? BackupWriteModes.Overwrite.Name : backup.WriteMode)
?? BackupWriteModes.Overwrite.Name;
BackupSourcePathText = GetFolderPath(backup.Source);
BackupDestinationPathText = GetFolderPath(backup.Destination);
TapeRows.Clear();
foreach (var tape in backup.Tapes)
TapeRows.Add(TapeRow.FromModel(tape));
RefreshAvailableTapeBarcodes();
}
private void ClearScheduleUi() {
foreach (var month in MonthOptions)
month.IsChecked = false;
foreach (var weekday in WeekdayOptions)
weekday.IsChecked = false;
RunTimes.Clear();
MinIntervalMinutesText = "10";
NewRunTimeText = "00:00";
}
private void LoadScheduleIntoUi(BackupSchedule? schedule) {
ClearScheduleUi();
if (schedule is null)
return;
foreach (var month in MonthOptions)
month.IsChecked = schedule.RunMonth.Any(name => string.Equals(name, month.Name, StringComparison.OrdinalIgnoreCase));
foreach (var weekday in WeekdayOptions)
weekday.IsChecked = schedule.RunWeekday.Any(name => string.Equals(name, weekday.Name, StringComparison.OrdinalIgnoreCase));
foreach (var time in schedule.RunTime)
RunTimes.Add(time);
MinIntervalMinutesText = schedule.MinIntervalMinutes.ToString();
}
private BackupSchedule ReadScheduleFromUi() {
var schedule = new BackupSchedule {
RunMonth = MonthOptions.Where(option => option.IsChecked).Select(option => option.Name).ToList(),
RunWeekday = WeekdayOptions.Where(option => option.IsChecked).Select(option => option.Name).ToList(),
RunTime = RunTimes.ToList()
};
if (!int.TryParse(MinIntervalMinutesText?.Trim(), out var minutes) || minutes < 0)
throw new InvalidOperationException("Minimum interval must be a non-negative integer.");
schedule.MinIntervalMinutes = minutes;
return schedule;
}
[RelayCommand]
private void AddTape() {
if (SelectedAvailableTape is null) {
Log("Select an available library barcode first.");
return;
}
var barcode = SelectedAvailableTape.Barcode;
if (TapeRows.Any(row => string.Equals(row.Barcode, barcode, StringComparison.OrdinalIgnoreCase))) {
Log("Tape '" + barcode + "' is already in the list.");
return;
}
TapeRows.Add(new TapeRow { Barcode = barcode });
RefreshAvailableTapeBarcodes();
}
[RelayCommand]
private void RemoveTape() {
if (SelectedTapeRow is null)
return;
TapeRows.Remove(SelectedTapeRow);
RefreshAvailableTapeBarcodes();
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void RefreshAvailableTapes() =>
Execute("Refresh available tapes", RefreshAvailableTapeBarcodes);
private void RefreshAvailableTapeBarcodes() {
if (!_configurationFileService.Current.IsTapeLibrary && !IsTapeLibraryUi) {
AvailableTapes.Clear();
return;
}
HashSet<string> associated;
try {
associated = CollectAssociatedBarcodes();
}
catch {
associated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
List<AvailableTapeOption> options;
try {
options = _libraryOrchestrator.Inventory()
.Where(element => !string.IsNullOrWhiteSpace(element.Barcode))
.GroupBy(element => element.Barcode!.Trim(), StringComparer.OrdinalIgnoreCase)
.Where(group => !associated.Contains(group.Key))
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => {
var element = group.First();
return new AvailableTapeOption {
Barcode = element.Barcode!.Trim(),
Display = $"{element.Barcode.Trim()} · {element.ElementType} {element.Slot}"
+ (string.IsNullOrWhiteSpace(element.CartridgeId) ? "" : (" · " + element.CartridgeId))
};
})
.ToList();
}
catch (Exception ex) {
AvailableTapes.Clear();
Log("Could not load library inventory for tape picker: " + ex.Message);
return;
}
AvailableTapes.Clear();
foreach (var option in options)
AvailableTapes.Add(option);
SelectedAvailableTape = null;
}
private HashSet<string> CollectAssociatedBarcodes() {
var associated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var editingName = SelectedSettingsBackup?.Name;
foreach (var backup in _configurationFileService.Current.Backups) {
var barcodes = string.IsNullOrWhiteSpace(editingName) || !string.Equals(backup.Name, editingName, StringComparison.OrdinalIgnoreCase)
? backup.Tapes.Select(tape => tape.Barcode)
: TapeRows.Select(row => row.Barcode);
foreach (var barcode in barcodes) {
if (!string.IsNullOrWhiteSpace(barcode))
associated.Add(barcode.Trim());
}
}
foreach (var row in TapeRows) {
if (!string.IsNullOrWhiteSpace(row.Barcode))
associated.Add(row.Barcode.Trim());
}
return associated;
}
[RelayCommand]
private void AddRunTime() {
var raw = NewRunTimeText?.Trim() ?? string.Empty;
if (!TimeOnly.TryParse(raw, out var parsed)) {
Log("Run time must be HH:mm (UTC).");
return;
}
var value = parsed.ToString("HH:mm");
if (!RunTimes.Contains(value))
RunTimes.Add(value);
NewRunTimeText = "00:00";
}
[RelayCommand]
private void RemoveRunTime() {
if (SelectedRunTime is null)
return;
RunTimes.Remove(SelectedRunTime);
}
private static string? MatchOption(IReadOnlyList<string> options, string? value) =>
options.FirstOrDefault(option => string.Equals(option, value, StringComparison.OrdinalIgnoreCase));
private static string GetFolderPath(WorkingFolder folder) {
if (folder.LocalPath is not null)
return folder.LocalPath.Path;
return folder.RemotePath?.Path ?? string.Empty;
}
private void ApplyFolderPath(WorkingFolder folder, string path) {
if (folder.RemotePath is not null) {
folder.RemotePath.Path = path;
return;
}
folder.LocalPath ??= new LocalPath { Path = path };
folder.LocalPath.Path = path;
folder.RemotePath = null;
}
private Configuration BuildConfigurationFromUi() {
var current = _configurationFileService.Current;
current.DeviceMode = DeviceMode;
current.Topology = SelectedTopology;
current.TapePath = TapePathText?.Trim() ?? string.Empty;
current.LibraryPath = LibraryPathText?.Trim() ?? string.Empty;
if (!int.TryParse(WriteDelayText?.Trim(), out var writeDelay) || writeDelay < 0)
throw new InvalidOperationException("Write delay must be a non-negative integer.");
current.WriteDelay = writeDelay;
current.FailFastOnChecksumMismatch = FailFastChecksum;
current.Emulator.DataDirectory = EmulatorDataDirText?.Trim() ?? string.Empty;
if (!int.TryParse(EmulatorSlotCountText?.Trim(), out var slots) || slots < 1)
throw new InvalidOperationException("Emulator slots must be a positive integer.");
if (!int.TryParse(EmulatorDriveCountText?.Trim(), out var drives) || drives < 1)
throw new InvalidOperationException("Emulator drives must be a positive integer.");
if (!int.TryParse(EmulatorIePortCountText?.Trim(), out var iePorts) || iePorts < 0)
throw new InvalidOperationException("Emulator I/E ports must be a non-negative integer.");
current.Emulator.Library.SlotCount = slots;
current.Emulator.Library.DriveCount = drives;
current.Emulator.Library.IePortCount = iePorts;
current.Service.ServiceName = ServiceNameText?.Trim() ?? "MaksIT.LTO.Backup";
current.Service.Description = ServiceDescriptionText?.Trim() ?? string.Empty;
current.Service.ExecutablePath = ServiceExePathSettingText?.Trim() ?? string.Empty;
_serviceManager = new HostServiceManager(current.Service.ServiceName);
if (SelectedSettingsBackup is BackupItem backup) {
backup.Name = BackupNameText?.Trim() ?? string.Empty;
backup.Barcode = BackupBarcodeText?.Trim() ?? string.Empty;
backup.Disabled = BackupDisabled;
backup.LTOGen = BackupLtoGen;
backup.WriteMode = string.IsNullOrWhiteSpace(BackupWriteMode) ? BackupWriteModes.Overwrite.Name : BackupWriteMode;
backup.Tapes = TapeRows
.Where(row => !string.IsNullOrWhiteSpace(row.Barcode))
.Select(row => row.ToModel())
.ToList();
ApplyFolderPath(backup.Source, BackupSourcePathText?.Trim() ?? string.Empty);
ApplyFolderPath(backup.Destination, BackupDestinationPathText?.Trim() ?? string.Empty);
}
ApplyAggregationFromUi(current);
return current;
}
private void RefreshAggregationLists() {
var selectedName = SelectedAggregation?.Name ?? _configurationFileService.Current.Aggregations.FirstOrDefault()?.Name;
Aggregations.Clear();
foreach (var aggregation in _configurationFileService.Current.Aggregations)
Aggregations.Add(aggregation);
SelectedAggregationAddJob = null;
if (Aggregations.Count == 0) {
_lastAggregationSelection = null;
LoadSelectedAggregationIntoUi(null);
return;
}
var selected = Aggregations.FirstOrDefault(item => string.Equals(item.Name, selectedName, StringComparison.OrdinalIgnoreCase))
?? Aggregations[0];
var restoreLoading = _loadingSettings;
_loadingSettings = true;
try {
SelectedAggregation = selected;
}
finally {
_loadingSettings = restoreLoading;
}
_lastAggregationSelection = selected;
LoadSelectedAggregationIntoUi(selected);
}
private void LoadSelectedAggregationIntoUi(BackupAggregation? aggregation) {
if (aggregation is null) {
AggregationNameText = string.Empty;
AggregationDisabled = false;
AggregationJobNames.Clear();
ClearScheduleUi();
LastRunUtcText = "Last wave run (UTC): (never)";
return;
}
AggregationNameText = aggregation.Name;
AggregationDisabled = aggregation.Disabled;
AggregationJobNames.Clear();
foreach (var jobName in aggregation.JobNames)
AggregationJobNames.Add(jobName);
LoadScheduleIntoUi(aggregation.Schedule ?? new BackupSchedule());
LastRunUtcText = aggregation.LastRunUtc.HasValue
? $"Last wave run (UTC): {aggregation.LastRunUtc:u}"
: "Last wave run (UTC): (never)";
}
private void ApplyAggregationFromUi(Configuration config, BackupAggregation? previous = null) {
var aggregation = previous ?? SelectedAggregation;
if (aggregation is null)
return;
aggregation.Name = AggregationNameText?.Trim() ?? aggregation.Name;
aggregation.Disabled = AggregationDisabled;
aggregation.JobNames = AggregationJobNames.ToList();
aggregation.Schedule = ReadScheduleFromUi();
}
[RelayCommand]
private void AddAggregation() {
ApplyAggregationFromUi(_configurationFileService.Current);
var aggregation = new BackupAggregation {
Name = $"Wave {_configurationFileService.Current.Aggregations.Count + 1}",
Schedule = new BackupSchedule {
MinIntervalMinutes = 60,
RunTime = ["00:00"]
}
};
_configurationFileService.Current.Aggregations.Add(aggregation);
RefreshAggregationLists();
SelectedAggregation = aggregation;
}
[RelayCommand]
private void RemoveAggregation() {
if (SelectedAggregation is null)
return;
_configurationFileService.Current.Aggregations.Remove(SelectedAggregation);
_lastAggregationSelection = null;
RefreshAggregationLists();
}
[RelayCommand]
private void AggregationAddJob() {
if (SelectedAggregationAddJob is null)
return;
var name = SelectedAggregationAddJob.Name;
if (AggregationJobNames.Any(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))) {
Log("Job '" + name + "' is already in this wave.");
return;
}
AggregationJobNames.Add(name);
}
[RelayCommand]
private void AggregationRemoveJob() {
if (SelectedAggregationJobName is null)
return;
AggregationJobNames.Remove(SelectedAggregationJobName);
}
[RelayCommand]
private void AggregationMoveJobUp() {
if (SelectedAggregationJobName is null)
return;
var index = AggregationJobNames.IndexOf(SelectedAggregationJobName);
if (index <= 0)
return;
var name = SelectedAggregationJobName;
AggregationJobNames.RemoveAt(index);
AggregationJobNames.Insert(index - 1, name);
SelectedAggregationJobName = name;
}
[RelayCommand]
private void AggregationMoveJobDown() {
if (SelectedAggregationJobName is null)
return;
var index = AggregationJobNames.IndexOf(SelectedAggregationJobName);
if (index < 0 || index >= AggregationJobNames.Count - 1)
return;
var name = SelectedAggregationJobName;
AggregationJobNames.RemoveAt(index);
AggregationJobNames.Insert(index + 1, name);
SelectedAggregationJobName = name;
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void SaveConfiguration() =>
Execute("Save configuration", SaveConfigurationFromUi);
private void SaveConfigurationFromUi() {
var configuration = BuildConfigurationFromUi();
_configurationFileService.Save(configuration);
RefreshBackupLists();
LoadSettingsIntoUi();
UpdateJobSummary(SelectedBackup);
RefreshMonitor(silent: true);
RefreshAvailableTapeBarcodes();
RefreshAggregationLists();
Log("Configuration saved to " + _configurationFileService.FilePath);
}
[RelayCommand(CanExecute = nameof(CanRunWhenIdle))]
private void ReloadConfiguration() =>
Execute("Reload configuration", () => {
_configurationFileService.Reload();
RefreshBackupLists();
LoadSettingsIntoUi();
UpdateJobSummary(SelectedBackup);
RefreshMonitor(silent: true);
RefreshAvailableTapeBarcodes();
RefreshAggregationLists();
Log("Configuration reloaded from " + _configurationFileService.FilePath);
});
}