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:
@@ -11,7 +11,7 @@
|
||||
|
||||
#define MyAppName "PROSERVE Launcher"
|
||||
#define MyAppShortName "PSLauncher"
|
||||
#define MyAppVersion "0.12.0"
|
||||
#define MyAppVersion "0.13.0"
|
||||
#define MyAppPublisher "ASTERION VR"
|
||||
#define MyAppURL "https://asterionvr.com"
|
||||
#define MyAppExeName "PSLauncher.exe"
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.12.0</Version>
|
||||
<AssemblyVersion>0.12.0.0</AssemblyVersion>
|
||||
<FileVersion>0.12.0.0</FileVersion>
|
||||
<Version>0.13.0</Version>
|
||||
<AssemblyVersion>0.13.0.0</AssemblyVersion>
|
||||
<FileVersion>0.13.0.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -444,6 +444,22 @@
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Content="{x:Static loc:Strings.SettingsReportAutoDeploy}" />
|
||||
|
||||
<!-- Conserver N backups -->
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="80" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{x:Static loc:Strings.SettingsReportMaxBackups}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding ReportMaxBackups, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||
</Grid>
|
||||
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -460,6 +476,52 @@
|
||||
Command="{Binding RedeployReportCommand}"
|
||||
IsEnabled="{Binding IsDeployingReport, Converter={StaticResource InverseBoolToBool}}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Liste des backups disponibles avec bouton Revert par ligne -->
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsReportBackups}"
|
||||
FontWeight="Bold" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,16,0,6" />
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" FontStyle="Italic"
|
||||
Visibility="{Binding HasReportBackups, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||
<ItemsControl ItemsSource="{Binding ReportBackups}"
|
||||
Visibility="{Binding HasReportBackups, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#1A1A20"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding DateDisplay}"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
FontSize="13" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding SizeDisplay}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding FolderName}"
|
||||
FontFamily="Consolas" FontSize="10"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="{x:Static loc:Strings.SettingsReportRevert}"
|
||||
Command="{Binding RevertCommand}"
|
||||
Padding="12,6" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,15 @@ public sealed class ReportToolConfig
|
||||
public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
|
||||
public string FolderName { get; set; } = "ProserveReport";
|
||||
public bool AutoDeploy { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de backups horodatés à conserver dans htdocs après chaque deploy.
|
||||
/// Format des dossiers : <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>.
|
||||
/// Permet le revert via Settings si le nouveau deploy a un bug visible.
|
||||
/// 3 par défaut = couvre les dernières releases sans grossir trop le disque.
|
||||
/// 0 = pas de backup (retourne au comportement v0.12.0).
|
||||
/// </summary>
|
||||
public int MaxBackups { get; set; } = 3;
|
||||
}
|
||||
|
||||
public sealed class LicenseConfig
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>PSLauncher.Updater</AssemblyName>
|
||||
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
||||
<Version>0.12.0</Version>
|
||||
<Version>0.13.0</Version>
|
||||
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
||||
<Company>ASTERION VR</Company>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
|
||||
Reference in New Issue
Block a user