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

@@ -511,6 +511,36 @@ public static class Strings
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
// ---- Backups Report tool ----
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
public static string SettingsReportRevert => T("↶ Revert", "↶ Revert", "↶ 还原", "↶ ย้อนกลับ", "↶ استرجاع");
public static string SettingsReportNoBackups => T(
"Aucun backup pour l'instant. Le premier sera créé au prochain deploy.",
"No backups yet. The first one will be created on the next deploy.",
"暂无备份。下次部署时将创建第一个。",
"ยังไม่มีสำรองข้อมูล จะสร้างครั้งแรกในการปรับใช้ครั้งถัดไป",
"لا توجد نسخ احتياطية بعد. سيتم إنشاء أول نسخة عند النشر التالي."
);
public static string SettingsReportBackupSize(string size) => T(
$"taille : {size}", $"size: {size}", $"大小:{size}", $"ขนาด: {size}", $"الحجم: {size}"
);
public static string MsgBoxRevertReport => T("Revert Outil Report", "Revert Report tool", "还原报告工具", "ย้อนกลับเครื่องมือรายงาน", "استرجاع أداة التقارير");
public static string MsgRevertReportConfirm(string date) => T(
$"Revenir à la version du {date} ?\n\nLa version actuellement déployée sera elle-même sauvegardée comme nouveau backup, donc tu pourras y revenir si besoin.",
$"Revert to the version from {date}?\n\nThe currently deployed version will itself be saved as a new backup, so you can switch back if needed.",
$"还原到 {date} 的版本?\n\n当前已部署的版本将作为新备份保存因此您可以在需要时切换回去。",
$"ย้อนกลับไปยังเวอร์ชันของ {date}?\n\nเวอร์ชันที่ปรับใช้อยู่ในปัจจุบันจะถูกบันทึกเป็นสำรองใหม่ ดังนั้นคุณสามารถสลับกลับได้หากต้องการ",
$"العودة إلى نسخة {date}؟\n\nسيتم حفظ النسخة المنشورة حالياً كنسخة احتياطية جديدة، لذا يمكنك التبديل إليها إذا لزم الأمر."
);
public static string MsgRevertReportSuccess(string date) => T(
$"✓ Reverted vers la version du {date}",
$"✓ Reverted to version from {date}",
$"✓ 已还原到 {date} 的版本",
$"✓ ย้อนกลับเป็นเวอร์ชันของ {date} แล้ว",
$"✓ تم الاسترجاع إلى نسخة {date}"
);
public static string MsgMigrationFailed(string detail) => T(
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",

View File

@@ -3,6 +3,7 @@ namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Déploie l'outil Report (page web hébergée dans XAMPP) depuis le sous-dossier
/// <c>_report/</c> bundled dans chaque ZIP PROSERVE vers le htdocs local.
/// Conserve N backups horodatés permettant un revert manuel via la UI Settings.
/// </summary>
public interface IReportToolDeployer
{
@@ -11,18 +12,28 @@ public interface IReportToolDeployer
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
/// <list type="number">
/// <item>copie récursive vers <c>{target}.new/</c></item>
/// <item>rename de l'existant vers <c>{target}.old/</c> (s'il existait)</item>
/// <item>rename de l'existant vers <c>{target}.backup-{yyyyMMdd-HHmmss}/</c></item>
/// <item>rename <c>.new</c> → final</item>
/// <item>delete de <c>.old</c> en best-effort</item>
/// <item>prune des vieux backups au-delà de <c>MaxBackups</c></item>
/// </list>
/// Si _report/ absent du ZIP : skip silencieux. Si htdocs introuvable :
/// <see cref="DeployStatus.XamppNotFound"/>. Si erreur disque :
/// <see cref="DeployStatus.Failed"/> + message d'erreur.
/// </summary>
Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct);
/// <summary>
/// Liste les backups horodatés disponibles dans <c>{HtdocsRoot}</c>, du plus
/// récent au plus ancien. Vide si aucun backup ou si htdocs introuvable.
/// </summary>
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
/// <summary>
/// Revient à un backup horodaté précis. Swap atomique : la version actuelle
/// est elle-même renommée en backup-{now} (donc le revert est lui-même
/// reversible), puis le backup choisi est renommé en version active.
/// </summary>
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
}
public enum DeployStatus
@@ -33,8 +44,10 @@ public enum DeployStatus
XamppNotFound,
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
Failed,
/// <summary>Déploiement réussi.</summary>
/// <summary>Déploiement (ou revert) réussi.</summary>
Deployed,
/// <summary>Revert demandé sur un backup qui n'existe pas / plus.</summary>
BackupNotFound,
}
public sealed record DeployResult(
@@ -45,3 +58,11 @@ public sealed record DeployResult(
string? ErrorMessage = null);
public sealed record DeployProgress(int FilesDone, int FilesTotal, string CurrentFilename);
/// <summary>
/// Métadonnées d'un backup horodaté présent dans <c>{HtdocsRoot}</c>.
/// </summary>
public sealed record BackupInfo(
string FolderName, // ProserveReport.backup-20260503-143022
DateTime TimestampUtc, // parsé depuis le suffixe du nom de dossier
long SizeBytes);

View File

@@ -1,17 +1,26 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Implémentation simple : copy récursif + atomic rename. L'atomicité du rename
/// garantit qu'Apache (qui sert les fichiers en parallèle) ne tombera jamais sur
/// un état mi-déployé. Au pire il sert l'ancienne version pendant 1ms le temps
/// du rename, puis sert la nouvelle.
/// Implémentation : copy récursif + atomic rename + backups horodatés.
/// L'atomicité du rename garantit qu'Apache (qui sert les fichiers en parallèle)
/// ne tombera jamais sur un état mi-déployé. Au pire il sert l'ancienne version
/// pendant 1ms le temps du rename, puis sert la nouvelle.
///
/// Backups : à chaque deploy, l'ancien dossier est conservé sous le nom
/// <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>. On garde les <c>MaxBackups</c>
/// plus récents et on supprime les autres en best-effort. Permet le revert
/// manuel via Settings → Avancés → Outil Report.
/// </summary>
public sealed class ReportToolDeployer : IReportToolDeployer
{
private const string SourceSubdir = "_report";
private const string BackupSuffix = ".backup-";
// Format trié-par-nom = trié-par-date (lexicographique == chronologique)
private const string TimestampFormat = "yyyyMMdd-HHmmss";
private readonly Func<ReportToolConfig> _configProvider;
private readonly ILogger<ReportToolDeployer> _logger;
@@ -47,15 +56,14 @@ public sealed class ReportToolDeployer : IReportToolDeployer
}
var stagingTarget = target + ".new";
var oldBackup = target + ".old";
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
// Nettoyage d'un éventuel résidu de tentative précédente
// Nettoyage d'un éventuel résidu de tentative précédente (le .new orphelin)
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
if (Directory.Exists(oldBackup)) Directory.Delete(oldBackup, recursive: true);
// Énumère pour avoir un total + reporter la progression
// Copy avec progress
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
int total = allFiles.Count;
long bytesTotal = 0;
@@ -79,29 +87,147 @@ public sealed class ReportToolDeployer : IReportToolDeployer
}
}, ct).ConfigureAwait(false);
// Atomic swap
if (Directory.Exists(target)) Directory.Move(target, oldBackup);
// Atomic swap : current → backup-{ts}, .new → current
if (Directory.Exists(target))
{
Directory.Move(target, backupTarget);
}
Directory.Move(stagingTarget, target);
// Cleanup ancienne version (best-effort, fichiers peuvent être verrouillés
// par Apache qui les sert encore — pas grave, sera nettoyé au prochain deploy)
if (Directory.Exists(oldBackup))
{
try { Directory.Delete(oldBackup, recursive: true); }
catch (Exception ex) { _logger.LogDebug(ex, "Could not clean .old backup, will retry next deploy"); }
}
// Prune des vieux backups (best-effort, ne bloque pas le retour)
PruneOldBackups(cfg);
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes)",
target, total, bytesTotal);
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
target, total, bytesTotal, backupTarget);
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deploy report tool to {Target}", target);
// Best-effort cleanup pour ne pas laisser de staging à moitié écrit
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
catch { /* ignore */ }
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
{
var cfg = _configProvider();
var prefix = cfg.FolderName + BackupSuffix;
if (!Directory.Exists(cfg.HtdocsRoot))
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
var list = new List<BackupInfo>();
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(dir);
var tsPart = name.Substring(prefix.Length);
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
{
continue; // dossier non conforme, on ignore
}
long size = 0;
try
{
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
size += new FileInfo(f).Length;
}
catch { /* size best-effort */ }
list.Add(new BackupInfo(name, ts, size));
}
// Plus récent en premier
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
}
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
{
var cfg = _configProvider();
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(backupPath))
{
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
$"Le backup {backupFolderName} n'existe plus.");
}
// Le revert lui-même crée un backup de l'état actuel, pour qu'on puisse
// revert le revert si besoin.
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
if (Directory.Exists(target))
Directory.Move(target, newBackupForCurrent);
Directory.Move(backupPath, target);
}, ct).ConfigureAwait(false);
// Compte les fichiers du backup restauré pour le retour
int files = 0;
long bytes = 0;
try
{
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
{
files++;
bytes += new FileInfo(f).Length;
}
}
catch { /* best-effort */ }
PruneOldBackups(cfg);
_logger.LogInformation("Reverted to backup {Backup} ({Files} files), previous current saved as {New}",
backupFolderName, files, newBackupForCurrent);
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert to {Backup} failed", backupFolderName);
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
/// <summary>
/// Garde les <c>MaxBackups</c> plus récents, supprime les autres en best-effort.
/// Si <c>MaxBackups = 0</c>, supprime tous les backups (mode "no history").
/// </summary>
private void PruneOldBackups(ReportToolConfig cfg)
{
try
{
var prefix = cfg.FolderName + BackupSuffix;
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
// Tri descending par nom = descending par date (yyyyMMdd-HHmmss est lexicographique)
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
.ToList();
int keep = Math.Max(0, cfg.MaxBackups);
for (int i = keep; i < dirs.Count; i++)
{
try
{
Directory.Delete(dirs[i].Path, recursive: true);
_logger.LogDebug("Pruned old report backup {Dir}", dirs[i].Name);
}
catch (Exception ex)
{
// Apache peut tenir un handle sur un fichier (logs ouverts, etc.).
// On retentera au prochain deploy. Pas bloquant.
_logger.LogDebug(ex, "Could not prune backup {Dir} (will retry next deploy)", dirs[i].Name);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Backup pruning skipped");
}
}
}