v0.13.0 — Report tool : timestamped backups + manual revert

The previous deploy mechanism kept the old version under {target}.old
just long enough to swap, then deleted it — no way back if the new
report had a runtime bug only visible at use time.

Now each deploy keeps the previous active version under
{FolderName}.backup-yyyyMMdd-HHmmss/. The N most recent are kept
(MaxBackups, default 3) and older ones are pruned in best-effort.

UI in Settings → Avancés → Outil Report :
- "Conserver N backups" input (0 = no history, back to v0.12 behavior)
- List of backups with date + size + cryptic folder name + per-row "↶ Revert"
- Confirmation dialog: "Revert to {date}? The currently deployed
  version will itself be saved as a new backup, so you can switch back."

Implementation
- IReportToolDeployer: + ListBackupsAsync, + RevertAsync, + BackupInfo,
  + DeployStatus.BackupNotFound.
- ReportToolDeployer: deploy renames {target} → backup-{ts}; PruneOldBackups
  trims to MaxBackups; RevertAsync atomically swaps current ↔ chosen
  backup, the previous current becoming itself a fresh backup.
- ReportBackupViewModel + DataTemplate for the list rows.
- Strings: backup labels, "↶ Revert", confirmation message.

Caveats documented in the design discussion:
- Backups cover ONLY the report tool files. DB migrations are not reverted
  (irreversible). If a migration broke the schema, that's a separate fix.
- No HTTP-HEAD post-deploy auto-revert: too fragile for false positives.
  Revert stays a deliberate manual action.

Versions bumped to 0.13.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 09:15:09 +02:00
parent 67f422539d
commit 562b4e5ed0
9 changed files with 359 additions and 31 deletions

View File

@@ -63,8 +63,12 @@ public sealed partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _reportHtdocsRoot;
[ObservableProperty] private string _reportFolderName;
[ObservableProperty] private bool _reportAutoDeploy;
[ObservableProperty] private int _reportMaxBackups;
[ObservableProperty] private string? _reportDeployStatus;
[ObservableProperty] private bool _isDeployingReport;
public System.Collections.ObjectModel.ObservableCollection<ReportBackupViewModel> ReportBackups { get; }
= new();
public bool HasReportBackups => ReportBackups.Count > 0;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available
@@ -164,6 +168,9 @@ public sealed partial class SettingsViewModel : ObservableObject
_reportHtdocsRoot = config.ReportTool.HtdocsRoot;
_reportFolderName = config.ReportTool.FolderName;
_reportAutoDeploy = config.ReportTool.AutoDeploy;
_reportMaxBackups = config.ReportTool.MaxBackups;
// Charge la liste de backups en arrière-plan (UI immédiate puis remplie quand prête)
_ = LoadReportBackupsAsync();
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId();
@@ -287,6 +294,7 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ReportTool.HtdocsRoot = ReportHtdocsRoot.Trim();
_config.ReportTool.FolderName = ReportFolderName.Trim();
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
_configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
@@ -494,4 +502,76 @@ public sealed partial class SettingsViewModel : ObservableObject
IsReplayingMigrations = false;
}
}
// ===================== REPORT TOOL BACKUPS =====================
private async Task LoadReportBackupsAsync()
{
try
{
var list = await _reportDeployer.ListBackupsAsync(CancellationToken.None);
ReportBackups.Clear();
foreach (var b in list)
ReportBackups.Add(new ReportBackupViewModel(b, RevertReportToBackupAsync));
OnPropertyChanged(nameof(HasReportBackups));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not list report backups");
}
}
/// <summary>Appelé par chaque ligne du tableau de backups via son own command.</summary>
private async Task RevertReportToBackupAsync(BackupInfo backup)
{
var localDate = backup.TimestampUtc.ToLocalTime();
var dateLabel = Strings.FormatLongDate(localDate);
var confirm = MessageBox.Show(
Strings.MsgRevertReportConfirm(dateLabel),
Strings.MsgBoxRevertReport,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
IsDeployingReport = true;
ReportDeployStatus = $"Revert vers {dateLabel}…";
try
{
var result = await _reportDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
ReportDeployStatus = result.Status == DeployStatus.Deployed
? Strings.MsgRevertReportSuccess(dateLabel)
: $"✗ {result.ErrorMessage}";
await LoadReportBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert report failed");
ReportDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingReport = false;
}
}
}
/// <summary>
/// Ligne du tableau "Backups disponibles" dans Settings → Avancés → Outil Report.
/// Chaque instance porte sa propre commande Revert paramétrée pour ce backup.
/// </summary>
public sealed partial class ReportBackupViewModel : ObservableObject
{
public BackupInfo Info { get; }
public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
public string FolderName => Info.FolderName;
private readonly Func<BackupInfo, Task> _revertHandler;
public ReportBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
{
Info = info;
_revertHandler = revertHandler;
}
[RelayCommand]
private Task Revert() => _revertHandler(Info);
}