diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 006c22a..749ea10 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.21.0" +#define MyAppVersion "0.22.0" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index c378d1c..f68343e 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -15,9 +15,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.21.0 - 0.21.0.0 - 0.21.0.0 + 0.22.0 + 0.22.0.0 + 0.22.0.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 2a4695b..04d1c8a 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -74,6 +74,14 @@ public sealed partial class MainViewModel : ObservableObject public ObservableCollection HealthIndicators { get; } = new(); public bool HasHealthIndicators => HealthIndicators.Count > 0; + /// + /// Cancelle les boucles de polling per-check actuellement en vol. Recréée à + /// chaque appel à pour permettre un redémarrage + /// propre après édition des checks dans Settings (hot-reload sans relancer + /// l'app). + /// + private CancellationTokenSource? _healthLoopCts; + /// /// Le bandeau santé n'a de sens que sur la page Library — Reports et /// Documentation sont des WebView pleine-page où afficher des pills @@ -319,41 +327,53 @@ public sealed partial class MainViewModel : ObservableObject } /// - /// Lance une tâche en arrière-plan qui exécute tous les checks en parallèle, - /// applique les résultats sur les VM via le dispatcher UI, puis attend - /// RefreshIntervalSeconds avant de recommencer. La tâche tourne tant que - /// l'app vit ; pas de cancel explicite (l'arrêt suit la fermeture du process). + /// Lance une boucle indépendante par check, chacune cadencée sur son propre + /// . Un Process check rapide + /// (~1 ms) peut tourner toutes les 1-2 s sans impact CPU, alors qu'un Ping + /// (qui coûte un round-trip réseau) tourne typiquement à 5-10 s. Premier + /// passage immédiat puis re-check selon l'intervalle. + /// + /// Cancelle la boucle précédente si elle existe (cas du hot-reload depuis + /// Settings → édition des checks). /// private void StartHealthLoop() { + // Stop l'ancienne boucle si on est en hot-reload (édition Settings) + try { _healthLoopCts?.Cancel(); } catch { /* déjà disposée */ } + _healthLoopCts?.Dispose(); + _healthLoopCts = null; + if (HealthIndicators.Count == 0) return; - var interval = TimeSpan.FromSeconds(Math.Max(2, _config.HealthChecks.RefreshIntervalSeconds)); - _ = Task.Run(async () => + + _healthLoopCts = new CancellationTokenSource(); + var ct = _healthLoopCts.Token; + + foreach (var vm in HealthIndicators.ToList()) { - while (true) + var localVm = vm; + // Floor de 500 ms pour éviter qu'un user mette 0/1 ms par erreur dans le json + // et sature un coeur CPU à pleins gaz. + var intervalMs = Math.Max(500, localVm.Entry.RefreshIntervalMs); + _ = Task.Run(async () => { - try + while (!ct.IsCancellationRequested) { - var snapshot = HealthIndicators.ToList(); - var tasks = snapshot.Select(async vm => + try { - try - { - var result = await _healthService.RunCheckAsync(vm.Entry, CancellationToken.None); - await Application.Current.Dispatcher.InvokeAsync(() => vm.Apply(result)); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Health check {Name} threw", vm.Entry.Name); - } - }); - await Task.WhenAll(tasks).ConfigureAwait(false); + var result = await _healthService.RunCheckAsync(localVm.Entry, ct).ConfigureAwait(false); + if (ct.IsCancellationRequested) break; + await Application.Current.Dispatcher.InvokeAsync(() => localVm.Apply(result)); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + _logger.LogDebug(ex, "Health check {Name} threw", localVm.Entry.Name); + } + try { await Task.Delay(intervalMs, ct).ConfigureAwait(false); } + catch { break; } } - catch (Exception ex) { _logger.LogDebug(ex, "Health loop tick"); } - try { await Task.Delay(interval).ConfigureAwait(false); } - catch { break; } - } - }); + }, ct); + } } /// @@ -546,6 +566,15 @@ public sealed partial class MainViewModel : ObservableObject _license = _licenseService.GetCached(); NotifyLicenseChanged(); RebuildList(); + + // Hot-reload du bandeau de santé : la liste des checks (et leurs intervals) + // a pu être éditée. On reconstruit les indicateurs et on relance la boucle + // per-check sans avoir à fermer/rouvrir le launcher. + InitHealthIndicators(); + StartHealthLoop(); + OnPropertyChanged(nameof(ReportUri)); + OnPropertyChanged(nameof(DocumentationUri)); + OnPropertyChanged(nameof(HasDocumentationUrl)); } } diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index e8ee19e..c05c0f6 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -85,6 +85,17 @@ public sealed partial class SettingsViewModel : ObservableObject = new(); public bool HasDocBackups => DocBackups.Count > 0; + // ----- Health checks (bandeau de santé système) ----- + /// + /// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne + /// pointe vers une copie modifiable de l'entrée (l'original dans + /// _config.HealthChecks.Checks n'est écrasé qu'à ), + /// pour que Cancel ne laisse pas de modifs partielles dans la config. + /// + public System.Collections.ObjectModel.ObservableCollection HealthChecks { get; } + = new(); + public bool HasHealthChecks => HealthChecks.Count > 0; + public IReadOnlyList AvailableLanguages { get; } = PSLauncher.Core.Localization.Strings.Available .Select(t => new LanguageOption(t.Code, t.Name)) @@ -193,6 +204,11 @@ public sealed partial class SettingsViewModel : ObservableObject _docAutoDeploy = config.DocTool.AutoDeploy; _docMaxBackups = config.DocTool.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) + HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck)); + // Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes) _ = LoadReportBackupsAsync(); _ = LoadDocBackupsAsync(); @@ -327,6 +343,11 @@ public sealed partial class SettingsViewModel : ObservableObject _config.DocTool.AutoDeploy = DocAutoDeploy; _config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups); + // 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. + _config.HealthChecks.Checks = HealthChecks.Select(r => r.Entry).ToList(); + _configStore.Save(_config); _logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged); @@ -633,6 +654,53 @@ public sealed partial class SettingsViewModel : ObservableObject } } + // ===================== HEALTH CHECKS ===================== + + [RelayCommand] + private void AddHealthCheck() + { + var entry = new HealthCheckEntry + { + Name = "", + Icon = "🔌", + Kind = "Process", + Target = "", + RefreshIntervalMs = 5000, + }; + var dlg = new HealthCheckEditorDialog(entry, isNew: true) + { + Owner = Application.Current.MainWindow + }; + if (dlg.ShowDialog() == true && dlg.Saved) + { + HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck)); + OnPropertyChanged(nameof(HasHealthChecks)); + } + } + + private void DeleteHealthCheck(HealthCheckRowViewModel row) + { + var confirm = ThemedMessageBox.Show( + Strings.SettingsHealthDeleteConfirm(string.IsNullOrEmpty(row.Name) ? row.Kind : row.Name), + Strings.MsgBoxConfirm, + MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + HealthChecks.Remove(row); + OnPropertyChanged(nameof(HasHealthChecks)); + } + + private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new() + { + Name = src.Name, + Icon = src.Icon, + Kind = src.Kind, + Target = src.Target, + RefreshIntervalMs = src.RefreshIntervalMs, + PingWarnMs = src.PingWarnMs, + PingErrorMs = src.PingErrorMs, + PingTimeoutMs = src.PingTimeoutMs, + }; + [RelayCommand] private async Task RedeployDocAsync() { @@ -697,6 +765,66 @@ public sealed partial class ReportBackupViewModel : ObservableObject private Task Revert() => _revertHandler(Info); } +/// +/// Ligne du tableau "Bandeau de santé système" dans Settings → Avancés. Pointe +/// vers une copie éditable de l'entrée (clonée à l'ouverture des Settings) ; +/// la persistence vers config.HealthChecks.Checks n'a lieu qu'au Save. +/// +public sealed partial class HealthCheckRowViewModel : ObservableObject +{ + public HealthCheckEntry Entry { get; private set; } + + /// Re-publié à chaque édition pour rafraîchir le label dans la liste. + [ObservableProperty] + private string _displayName; + + [ObservableProperty] + private string _displayIcon; + + [ObservableProperty] + private string _displayDetail; + + public string Name => Entry.Name; + public string Kind => Entry.Kind; + + private readonly Action _onDelete; + + public HealthCheckRowViewModel(HealthCheckEntry entry, Action onDelete) + { + Entry = entry; + _onDelete = onDelete; + _displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name; + _displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon; + _displayDetail = ComputeDetail(entry); + } + + private static string ComputeDetail(HealthCheckEntry e) + { + var kind = e.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true ? "Ping" : "Process"; + var target = string.IsNullOrEmpty(e.Target) ? "—" : e.Target; + return $"{kind} · {target} · {e.RefreshIntervalMs} ms"; + } + + [RelayCommand] + private void Edit() + { + var dlg = new PSLauncher.App.Views.HealthCheckEditorDialog(Entry) + { + Owner = Application.Current.MainWindow + }; + if (dlg.ShowDialog() == true && dlg.Saved) + { + // Entry a été muté in-place par le dialog. Refresh des bindings d'affichage. + DisplayName = string.IsNullOrEmpty(Entry.Name) ? "(sans nom)" : Entry.Name; + DisplayIcon = string.IsNullOrEmpty(Entry.Icon) ? "🔌" : Entry.Icon; + DisplayDetail = ComputeDetail(Entry); + } + } + + [RelayCommand] + private void Delete() => _onDelete(this); +} + /// Variante doc — même structure que . public sealed partial class DocBackupViewModel : ObservableObject { diff --git a/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml new file mode 100644 index 0000000..4a2b3d1 --- /dev/null +++ b/src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + public string Target { get; set; } = ""; + /// + /// Période entre 2 vérifications de CE check, en millisecondes. Indépendant + /// pour chaque entry car un Process check est rapide (~1ms) et peut tourner + /// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut + /// tourner toutes les 5-10 s. Default 5000 ms. + /// + public int RefreshIntervalMs { get; set; } = 5000; + /// Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms. public int? PingWarnMs { get; set; } = 50;