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 MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PSLauncher"
|
#define MyAppShortName "PSLauncher"
|
||||||
#define MyAppVersion "0.12.0"
|
#define MyAppVersion "0.13.0"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PSLauncher.exe"
|
#define MyAppExeName "PSLauncher.exe"
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.12.0</Version>
|
<Version>0.13.0</Version>
|
||||||
<AssemblyVersion>0.12.0.0</AssemblyVersion>
|
<AssemblyVersion>0.13.0.0</AssemblyVersion>
|
||||||
<FileVersion>0.12.0.0</FileVersion>
|
<FileVersion>0.13.0.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -63,8 +63,12 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _reportHtdocsRoot;
|
[ObservableProperty] private string _reportHtdocsRoot;
|
||||||
[ObservableProperty] private string _reportFolderName;
|
[ObservableProperty] private string _reportFolderName;
|
||||||
[ObservableProperty] private bool _reportAutoDeploy;
|
[ObservableProperty] private bool _reportAutoDeploy;
|
||||||
|
[ObservableProperty] private int _reportMaxBackups;
|
||||||
[ObservableProperty] private string? _reportDeployStatus;
|
[ObservableProperty] private string? _reportDeployStatus;
|
||||||
[ObservableProperty] private bool _isDeployingReport;
|
[ObservableProperty] private bool _isDeployingReport;
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<ReportBackupViewModel> ReportBackups { get; }
|
||||||
|
= new();
|
||||||
|
public bool HasReportBackups => ReportBackups.Count > 0;
|
||||||
|
|
||||||
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
||||||
PSLauncher.Core.Localization.Strings.Available
|
PSLauncher.Core.Localization.Strings.Available
|
||||||
@@ -164,6 +168,9 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_reportHtdocsRoot = config.ReportTool.HtdocsRoot;
|
_reportHtdocsRoot = config.ReportTool.HtdocsRoot;
|
||||||
_reportFolderName = config.ReportTool.FolderName;
|
_reportFolderName = config.ReportTool.FolderName;
|
||||||
_reportAutoDeploy = config.ReportTool.AutoDeploy;
|
_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";
|
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||||
MachineId = licenseService.GetMachineId();
|
MachineId = licenseService.GetMachineId();
|
||||||
@@ -287,6 +294,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.ReportTool.HtdocsRoot = ReportHtdocsRoot.Trim();
|
_config.ReportTool.HtdocsRoot = ReportHtdocsRoot.Trim();
|
||||||
_config.ReportTool.FolderName = ReportFolderName.Trim();
|
_config.ReportTool.FolderName = ReportFolderName.Trim();
|
||||||
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
|
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
|
||||||
|
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
|
||||||
|
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
||||||
@@ -494,4 +502,76 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
IsReplayingMigrations = false;
|
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}"
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
Content="{x:Static loc:Strings.SettingsReportAutoDeploy}" />
|
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 Margin="0,12,0,0">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
@@ -460,6 +476,52 @@
|
|||||||
Command="{Binding RedeployReportCommand}"
|
Command="{Binding RedeployReportCommand}"
|
||||||
IsEnabled="{Binding IsDeployingReport, Converter={StaticResource InverseBoolToBool}}" />
|
IsEnabled="{Binding IsDeployingReport, Converter={StaticResource InverseBoolToBool}}" />
|
||||||
</Grid>
|
</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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -511,6 +511,36 @@ public static class Strings
|
|||||||
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
|
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
|
||||||
);
|
);
|
||||||
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
|
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(
|
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.",
|
$"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.",
|
$"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>
|
/// <summary>
|
||||||
/// Déploie l'outil Report (page web hébergée dans XAMPP) depuis le sous-dossier
|
/// 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.
|
/// <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>
|
/// </summary>
|
||||||
public interface IReportToolDeployer
|
public interface IReportToolDeployer
|
||||||
{
|
{
|
||||||
@@ -11,18 +12,28 @@ public interface IReportToolDeployer
|
|||||||
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
|
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item>copie récursive vers <c>{target}.new/</c></item>
|
/// <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>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>
|
/// </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>
|
/// </summary>
|
||||||
Task<DeployResult> DeployAsync(
|
Task<DeployResult> DeployAsync(
|
||||||
string installFolder,
|
string installFolder,
|
||||||
IProgress<DeployProgress>? progress,
|
IProgress<DeployProgress>? progress,
|
||||||
CancellationToken ct);
|
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
|
public enum DeployStatus
|
||||||
@@ -33,8 +44,10 @@ public enum DeployStatus
|
|||||||
XamppNotFound,
|
XamppNotFound,
|
||||||
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
|
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
|
||||||
Failed,
|
Failed,
|
||||||
/// <summary>Déploiement réussi.</summary>
|
/// <summary>Déploiement (ou revert) réussi.</summary>
|
||||||
Deployed,
|
Deployed,
|
||||||
|
/// <summary>Revert demandé sur un backup qui n'existe pas / plus.</summary>
|
||||||
|
BackupNotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record DeployResult(
|
public sealed record DeployResult(
|
||||||
@@ -45,3 +58,11 @@ public sealed record DeployResult(
|
|||||||
string? ErrorMessage = null);
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
public sealed record DeployProgress(int FilesDone, int FilesTotal, string CurrentFilename);
|
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 Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.Core.ReportTool;
|
namespace PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implémentation simple : copy récursif + atomic rename. L'atomicité du rename
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||||
/// garantit qu'Apache (qui sert les fichiers en parallèle) ne tombera jamais sur
|
/// L'atomicité du rename garantit qu'Apache (qui sert les fichiers en parallèle)
|
||||||
/// un état mi-déployé. Au pire il sert l'ancienne version pendant 1ms le temps
|
/// ne tombera jamais sur un état mi-déployé. Au pire il sert l'ancienne version
|
||||||
/// du rename, puis sert la nouvelle.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class ReportToolDeployer : IReportToolDeployer
|
public sealed class ReportToolDeployer : IReportToolDeployer
|
||||||
{
|
{
|
||||||
private const string SourceSubdir = "_report";
|
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 Func<ReportToolConfig> _configProvider;
|
||||||
private readonly ILogger<ReportToolDeployer> _logger;
|
private readonly ILogger<ReportToolDeployer> _logger;
|
||||||
@@ -47,15 +56,14 @@ public sealed class ReportToolDeployer : IReportToolDeployer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
var oldBackup = target + ".old";
|
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
try
|
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(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();
|
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
|
||||||
int total = allFiles.Count;
|
int total = allFiles.Count;
|
||||||
long bytesTotal = 0;
|
long bytesTotal = 0;
|
||||||
@@ -79,29 +87,147 @@ public sealed class ReportToolDeployer : IReportToolDeployer
|
|||||||
}
|
}
|
||||||
}, ct).ConfigureAwait(false);
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// Atomic swap
|
// Atomic swap : current → backup-{ts}, .new → current
|
||||||
if (Directory.Exists(target)) Directory.Move(target, oldBackup);
|
if (Directory.Exists(target))
|
||||||
|
{
|
||||||
|
Directory.Move(target, backupTarget);
|
||||||
|
}
|
||||||
Directory.Move(stagingTarget, target);
|
Directory.Move(stagingTarget, target);
|
||||||
|
|
||||||
// Cleanup ancienne version (best-effort, fichiers peuvent être verrouillés
|
// Prune des vieux backups (best-effort, ne bloque pas le retour)
|
||||||
// par Apache qui les sert encore — pas grave, sera nettoyé au prochain deploy)
|
PruneOldBackups(cfg);
|
||||||
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"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes)",
|
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
|
||||||
target, total, bytesTotal);
|
target, total, bytesTotal, backupTarget);
|
||||||
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
|
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to deploy report tool to {Target}", target);
|
_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); }
|
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
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 HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
|
||||||
public string FolderName { get; set; } = "ProserveReport";
|
public string FolderName { get; set; } = "ProserveReport";
|
||||||
public bool AutoDeploy { get; set; } = true;
|
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
|
public sealed class LicenseConfig
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyName>PSLauncher.Updater</AssemblyName>
|
<AssemblyName>PSLauncher.Updater</AssemblyName>
|
||||||
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
||||||
<Version>0.12.0</Version>
|
<Version>0.13.0</Version>
|
||||||
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
||||||
<Company>ASTERION VR</Company>
|
<Company>ASTERION VR</Company>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
|
|||||||
Reference in New Issue
Block a user