v0.24.9 — Deploy _api/, fix WebView2 Program Files, DL robustness

Deploy auto de l'API PHP de stats (parallèle Report/Doc) :
- ApiToolDeployer : copy _api/ → C:\xampp\htdocs\proserve\ avec atomic
  rename + backups horodatés + revert + pruning. Strict clone du pattern
  DocToolDeployer.
- ApiToolConfig dans LocalConfig (default folder "proserve" en minuscule
  pour matcher l'install standard PROSERVE côté client).
- Settings → Avancés : section dédiée (HtdocsRoot, FolderName, AutoDeploy,
  MaxBackups, Re-déployer, liste backups + revert) + ApiBackupViewModel.
- DI registration dans App.xaml.cs, injection MainViewModel, appel
  post-install après le bloc Doc.
- 8 nouvelles strings i18n (5 langues).

Fix WebView2 vide en Program Files :
- Set WEBVIEW2_USER_DATA_FOLDER vers %LocalAppData%\PSLauncher\WebView2\
  dans App.OnStartup. Sans ça, WebView2 essaie d'écrire son cache à côté
  du .exe (read-only en Program Files) → init silencieusement KO →
  onglets Report/Doc vides.

Refresh auto WebView2 sur navigation d'onglet :
- Hook IsVisibleChanged sur ReportWebView et DocsWebView. À chaque
  ré-affichage, CoreWebView2.Reload() pour voir les données serveur à
  jour. Le binding Source garde la nav initiale au cold start.

Fix UI : Cancel décalant le footer pendant Check updates :
- Nouveau flag IsDownloadActive distinct de IsBusy. Visibility du bouton
  Cancel passe de IsBusy → IsDownloadActive : couvre uniquement les DL,
  plus aucune apparition pendant Check updates / Install / Uninstall /
  Self-update qui décalait tout le layout 1 s.

DL robustness (multi-PC) :
- SafeInstallAsync : error boundary sur l'install handler. Plus aucune
  exception silencieuse — log + popup d'erreur visible (résout les cas
  "le 2e PC ne démarre pas, sans message").
- DownloadManager : vérif explicite que CHAQUE segment a Completed=true
  && DownloadedBytes >= Length AVANT le SHA-256. Le check actualSize
  était inutile (fichier sparse-préalloué).
- MaxRetryAttempts Polly 6 → 10 pour absorber les outages PHP-FPM
  request_terminate_timeout sous charge multi-PC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 13:53:13 +02:00
parent 9de8e95910
commit 7de0c01b97
13 changed files with 693 additions and 8 deletions

View File

@@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Health;
using PSLauncher.Core.Health.OpenVRInterop;
@@ -69,6 +70,19 @@ public partial class App : Application
"PSLauncher", "logs");
Directory.CreateDirectory(LogsDirectory);
// WebView2 a besoin d'écrire son UserDataFolder (cache, cookies, GPU shader cache,
// etc.). Par défaut il essaie à côté du .exe — ce qui échoue silencieusement quand
// on est installé en Program Files (read-only sans admin) → onglets Report/Doc
// vides. On force le user-data-folder dans %LocalAppData%\PSLauncher\WebView2 via
// la var d'environnement officielle, lue par WebView2 à la première initialisation.
// À setter AVANT que tout contrôle WebView2 ne soit instancié → ici, dans OnStartup,
// est largement assez tôt (la MainWindow n'est créée que plus bas).
var webViewDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "WebView2");
Directory.CreateDirectory(webViewDataDir);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", webViewDataDir, EnvironmentVariableTarget.Process);
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
// langue avant que le moindre élément XAML soit instancié.
try
@@ -204,6 +218,13 @@ public partial class App : Application
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
// Déploiement de l'API PHP de stats (parallèle à Report/Doc) :
// copie atomique de _api/ → C:\xampp\htdocs\ProserveApi\
services.AddSingleton<IApiToolDeployer>(sp =>
new ApiToolDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
services.AddSingleton<IOpenVrService, OpenVrService>();
services.AddSingleton<ISystemHealthService, SystemHealthService>();

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.24.5</Version>
<AssemblyVersion>0.24.5.0</AssemblyVersion>
<FileVersion>0.24.5.0</FileVersion>
<Version>0.24.9</Version>
<AssemblyVersion>0.24.9.0</AssemblyVersion>
<FileVersion>0.24.9.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -17,6 +17,7 @@ using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Health;
using PSLauncher.Core.Migrations;
@@ -49,6 +50,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer;
private readonly ISystemHealthService _healthService;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger;
@@ -112,6 +114,15 @@ public sealed partial class MainViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
private bool _isBusy;
/// <summary>
/// Flag dédié au téléchargement en cours, distinct de <see cref="IsBusy"/> qui
/// couvre AUSSI Check updates / Install / Uninstall / Self-update. Sert à scoper
/// la visibilité du bouton "Annuler" du footer aux seuls DL — sinon il apparaît
/// 1 s pendant un Check updates et décale tout le layout.
/// </summary>
[ObservableProperty]
private bool _isDownloadActive;
[ObservableProperty] private double _progressPercent;
/// <summary>
@@ -311,6 +322,7 @@ public sealed partial class MainViewModel : ObservableObject
IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
ISystemHealthService healthService,
IServiceProvider serviceProvider,
ILogger<MainViewModel> logger)
@@ -329,6 +341,7 @@ public sealed partial class MainViewModel : ObservableObject
_migrationService = migrationService;
_reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_healthService = healthService;
_serviceProvider = serviceProvider;
_logger = logger;
@@ -516,13 +529,43 @@ public sealed partial class MainViewModel : ObservableObject
private void WireRowHandlers(VersionRowViewModel row)
{
row.LaunchHandler = LaunchVersion;
row.InstallHandler = r => _activeInstallTask = InstallVersionAsync(r);
// Wrap InstallVersionAsync via SafeInstallAsync : sans ça, une exception
// levée AVANT le premier await (ex. ThemedMessageBox.Show dans le check
// "IsBusy") tomberait dans le vide (handler sync void) et la UI resterait
// figée sans message — exactement le symptôme "le 2e PC ne démarre pas".
row.InstallHandler = r => _activeInstallTask = SafeInstallAsync(r);
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
}
/// <summary>
/// Error boundary autour de <see cref="InstallVersionAsync"/>. Garantit qu'aucune
/// exception ne disparaît silencieusement (cas du fire-and-forget pré-0.24.6 où
/// le launcher pouvait rester figé sur un click sans aucun feedback).
/// </summary>
private async Task SafeInstallAsync(VersionRowViewModel row)
{
try
{
await InstallVersionAsync(row);
}
catch (Exception ex)
{
_logger.LogError(ex, "InstallVersionAsync unhandled exception (row={Version})", row.Version);
StatusMessage = Strings.StatusError(ex.Message);
try
{
ThemedMessageBox.Show(
Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError,
MessageBoxButton.OK, MessageBoxImage.Error);
}
catch { /* dialog peut échouer si on est en train de quitter */ }
}
}
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
private async Task CheckForUpdatesAsync()
{
@@ -826,6 +869,7 @@ public sealed partial class MainViewModel : ObservableObject
if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
IsBusy = true;
IsDownloadActive = true;
_activeRow = row;
_activeDownloadCts = new CancellationTokenSource();
var ct = _activeDownloadCts.Token;
@@ -1059,6 +1103,49 @@ public sealed partial class MainViewModel : ObservableObject
}
}
// 8) Déploiement de l'API PHP de stats (parallèle à Report/Doc).
// Cherche _api/ dans le ZIP extrait et copie vers htdocs en
// atomic rename. Skip silencieux si _api/ absent du ZIP.
if (_config.ApiTool.AutoDeploy)
{
StatusMessage = Strings.StatusDeployingApi(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
var apProgress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
{
var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
}
var label = Strings.ProgressApiDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
ProgressDetail = label;
row.ProgressDetail = label;
});
var apResult = await _apiDeployer.DeployAsync(target, apProgress, ct);
switch (apResult.Status)
{
case DeployStatus.Deployed:
_logger.LogInformation("Api tool deployed ({N} files) for v{Version}", apResult.FilesDeployed, row.Version);
break;
case DeployStatus.SkippedNoSourceFolder:
_logger.LogInformation("No _api/ in v{Version} ZIP, api tool unchanged", row.Version);
break;
case DeployStatus.XamppNotFound:
// Idem Doc : warning XAMPP déjà éventuellement affiché par Report,
// on log silencieusement pour ne pas multiplier les popups identiques.
_logger.LogWarning("Api tool deploy : XAMPP htdocs not found at {Root}", _config.ApiTool.HtdocsRoot);
break;
case DeployStatus.Failed:
ThemedMessageBox.Show(
Strings.MsgApiDeployFailed(apResult.ErrorMessage ?? "?"),
Strings.MsgBoxApiDeployFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
@@ -1100,6 +1187,7 @@ public sealed partial class MainViewModel : ObservableObject
_activeRow = null;
_activeInstallTask = null;
IsBusy = false;
IsDownloadActive = false;
}
}

View File

@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using PSLauncher.App.Views;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
@@ -35,6 +36,7 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer;
private readonly IInstallationRegistry _registry;
private readonly HttpClient _http;
private readonly ILogger<SettingsViewModel> _logger;
@@ -85,6 +87,17 @@ public sealed partial class SettingsViewModel : ObservableObject
= new();
public bool HasDocBackups => DocBackups.Count > 0;
// ----- API Tool deploy settings (mirror of Report/Doc) -----
[ObservableProperty] private string _apiHtdocsRoot;
[ObservableProperty] private string _apiFolderName;
[ObservableProperty] private bool _apiAutoDeploy;
[ObservableProperty] private int _apiMaxBackups;
[ObservableProperty] private string? _apiDeployStatus;
[ObservableProperty] private bool _isDeployingApi;
public System.Collections.ObjectModel.ObservableCollection<ApiBackupViewModel> ApiBackups { get; }
= new();
public bool HasApiBackups => ApiBackups.Count > 0;
// ----- Health checks (bandeau de santé système) -----
/// <summary>
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
@@ -165,6 +178,7 @@ public sealed partial class SettingsViewModel : ObservableObject
IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
IInstallationRegistry registry,
HttpClient http,
ILogger<SettingsViewModel> logger)
@@ -176,6 +190,7 @@ public sealed partial class SettingsViewModel : ObservableObject
_migrationService = migrationService;
_reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_registry = registry;
_http = http;
_logger = logger;
@@ -204,6 +219,11 @@ public sealed partial class SettingsViewModel : ObservableObject
_docAutoDeploy = config.DocTool.AutoDeploy;
_docMaxBackups = config.DocTool.MaxBackups;
_apiHtdocsRoot = config.ApiTool.HtdocsRoot;
_apiFolderName = config.ApiTool.FolderName;
_apiAutoDeploy = config.ApiTool.AutoDeploy;
_apiMaxBackups = config.ApiTool.MaxBackups;
// Snapshot éditable des health checks. On clone chaque entry pour ne pas
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
foreach (var entry in config.HealthChecks.Checks)
@@ -212,6 +232,7 @@ public sealed partial class SettingsViewModel : ObservableObject
// Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
_ = LoadReportBackupsAsync();
_ = LoadDocBackupsAsync();
_ = LoadApiBackupsAsync();
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId();
@@ -343,6 +364,11 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.DocTool.AutoDeploy = DocAutoDeploy;
_config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups);
_config.ApiTool.HtdocsRoot = ApiHtdocsRoot.Trim();
_config.ApiTool.FolderName = ApiFolderName.Trim();
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
// Health checks : on remplace intégralement la liste persistée par
// les copies éditées. Les originaux restent en mémoire de toute façon
// (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
@@ -781,6 +807,96 @@ public sealed partial class SettingsViewModel : ObservableObject
IsDeployingDoc = false;
}
}
// ===================== API TOOL BACKUPS (mirror of Report) =====================
private async Task LoadApiBackupsAsync()
{
try
{
var list = await _apiDeployer.ListBackupsAsync(CancellationToken.None);
ApiBackups.Clear();
foreach (var b in list)
ApiBackups.Add(new ApiBackupViewModel(b, RevertApiToBackupAsync));
OnPropertyChanged(nameof(HasApiBackups));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not list api backups");
}
}
private async Task RevertApiToBackupAsync(BackupInfo backup)
{
var localDate = backup.TimestampUtc.ToLocalTime();
var dateLabel = Strings.FormatLongDate(localDate);
var confirm = ThemedMessageBox.Show(
Strings.MsgRevertReportConfirm(dateLabel),
Strings.MsgBoxRevertReport,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
IsDeployingApi = true;
ApiDeployStatus = $"Revert vers {dateLabel}…";
try
{
var result = await _apiDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
ApiDeployStatus = result.Status == DeployStatus.Deployed
? Strings.MsgRevertReportSuccess(dateLabel)
: $"✗ {result.ErrorMessage}";
await LoadApiBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert api failed");
ApiDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingApi = false;
}
}
[RelayCommand]
private async Task RedeployApiAsync()
{
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
if (installed is null)
{
ApiDeployStatus = "Aucune version installée — installe d'abord une version qui contient _api/.";
return;
}
IsDeployingApi = true;
ApiDeployStatus = $"Déploiement depuis v{installed.Version}…";
try
{
Save();
var progress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
ApiDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
});
var result = await _apiDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
ApiDeployStatus = result.Status switch
{
DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _api/ — rien à déployer",
DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}",
DeployStatus.Failed => $"✗ {result.ErrorMessage}",
_ => "?",
};
await LoadApiBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Redeploy api failed");
ApiDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingApi = false;
}
}
}
/// <summary>
@@ -899,3 +1015,22 @@ public sealed partial class DocBackupViewModel : ObservableObject
[RelayCommand]
private Task Revert() => _revertHandler(Info);
}
/// <summary>Variante api — même structure que <see cref="ReportBackupViewModel"/>.</summary>
public sealed partial class ApiBackupViewModel : 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 ApiBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
{
Info = info;
_revertHandler = revertHandler;
}
[RelayCommand]
private Task Revert() => _revertHandler(Info);
}

View File

@@ -767,7 +767,7 @@
Content="{x:Static loc:Strings.ActionCancel}"
VerticalAlignment="Center" Margin="12,0,0,0"
Command="{Binding CancelDownloadCommand}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding IsDownloadActive, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
</Grid>

View File

@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interop;
using Microsoft.Web.WebView2.Wpf;
using PSLauncher.App.ViewModels;
using PSLauncher.Core.Localization;
@@ -90,6 +91,24 @@ public partial class MainWindow : Window
InitializeComponent();
SourceInitialized += MainWindow_SourceInitialized;
Closing += MainWindow_Closing;
// Force un reload de la WebView quand son onglet redevient visible
// (Library → Report ou Library → Doc), pour que l'utilisateur voie
// toujours les données les plus à jour côté serveur PHP. Sans ça,
// WebView2 garde la page en cache de la première navigation et ne
// refresh jamais. Au tout premier affichage CoreWebView2 n'est pas
// encore initialisée — le binding Source fait alors la nav initiale,
// les reloads suivants kicker uniquement sur les changements d'onglet.
ReportWebView.IsVisibleChanged += OnWebViewBecameVisible;
DocsWebView.IsVisibleChanged += OnWebViewBecameVisible;
}
private static void OnWebViewBecameVisible(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.NewValue is true && sender is WebView2 wv && wv.CoreWebView2 is not null)
{
wv.CoreWebView2.Reload();
}
}
/// <summary>

View File

@@ -538,6 +538,116 @@
</StackPanel>
</Border>
<!-- API PHP de stats (XAMPP htdocs deploy, mirror of Report/Doc) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsApiTool}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding ApiHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding ApiFolderName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<CheckBox IsChecked="{Binding ApiAutoDeploy}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsApiAutoDeploy}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsApiMaxBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding ApiMaxBackups, 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="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding ApiDeployStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsApiRedeploy}"
Command="{Binding RedeployApiCommand}"
IsEnabled="{Binding IsDeployingApi, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsApiBackups}"
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 HasApiBackups, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding ApiBackups}"
Visibility="{Binding HasApiBackups, 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>
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"