v0.22.0 — Health checks editor : icon picker + per-check refresh

Settings → Avancés → Bandeau de santé : liste éditable des checks (Add /
Edit / Delete) avec dialog modal pour chaque entry. Picker d'emoji parmi
28 icônes curatées (VR / streaming / réseau / système). Édition du nom,
type (Process / Ping), cible, intervalle de refresh en ms (per-check),
et seuils Ping (warn / error / timeout) sous une section Avancé.

La config est persistée dans %LocalAppData%\PSLauncher\config.json donc
elle survit aux auto-updates du launcher. Modifiable aussi à la main
dans le json pour les power-users.

Le bandeau hot-reload après Save : cancel des boucles de polling
existantes, reconstruction des HealthIndicators depuis la config fraîche,
puis relance d'une boucle dédiée par check (chacune cadencée sur son
propre RefreshIntervalMs — Process check rapide peut tourner à 1 s,
Ping reste à 5-10 s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:09:39 +02:00
parent 741e30b7ae
commit 211af9dd85
9 changed files with 794 additions and 30 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.21.0" #define MyAppVersion "0.22.0"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"

View File

@@ -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.21.0</Version> <Version>0.22.0</Version>
<AssemblyVersion>0.21.0.0</AssemblyVersion> <AssemblyVersion>0.22.0.0</AssemblyVersion>
<FileVersion>0.21.0.0</FileVersion> <FileVersion>0.22.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>

View File

@@ -74,6 +74,14 @@ public sealed partial class MainViewModel : ObservableObject
public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new(); public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
public bool HasHealthIndicators => HealthIndicators.Count > 0; public bool HasHealthIndicators => HealthIndicators.Count > 0;
/// <summary>
/// Cancelle les boucles de polling per-check actuellement en vol. Recréée à
/// chaque appel à <see cref="StartHealthLoop"/> pour permettre un redémarrage
/// propre après édition des checks dans Settings (hot-reload sans relancer
/// l'app).
/// </summary>
private CancellationTokenSource? _healthLoopCts;
/// <summary> /// <summary>
/// Le bandeau santé n'a de sens que sur la page Library — Reports et /// Le bandeau santé n'a de sens que sur la page Library — Reports et
/// Documentation sont des WebView pleine-page où afficher des pills /// Documentation sont des WebView pleine-page où afficher des pills
@@ -319,41 +327,53 @@ public sealed partial class MainViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// Lance une tâche en arrière-plan qui exécute tous les checks en parallèle, /// Lance une boucle indépendante par check, chacune cadencée sur son propre
/// applique les résultats sur les VM via le dispatcher UI, puis attend /// <see cref="HealthCheckEntry.RefreshIntervalMs"/>. Un Process check rapide
/// RefreshIntervalSeconds avant de recommencer. La tâche tourne tant que /// (~1 ms) peut tourner toutes les 1-2 s sans impact CPU, alors qu'un Ping
/// l'app vit ; pas de cancel explicite (l'arrêt suit la fermeture du process). /// (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).
/// </summary> /// </summary>
private void StartHealthLoop() 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; 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(); try
var tasks = snapshot.Select(async vm =>
{ {
try var result = await _healthService.RunCheckAsync(localVm.Entry, ct).ConfigureAwait(false);
{ if (ct.IsCancellationRequested) break;
var result = await _healthService.RunCheckAsync(vm.Entry, CancellationToken.None); await Application.Current.Dispatcher.InvokeAsync(() => localVm.Apply(result));
await Application.Current.Dispatcher.InvokeAsync(() => vm.Apply(result)); }
} catch (OperationCanceledException) { break; }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "Health check {Name} threw", vm.Entry.Name); _logger.LogDebug(ex, "Health check {Name} threw", localVm.Entry.Name);
} }
}); try { await Task.Delay(intervalMs, ct).ConfigureAwait(false); }
await Task.WhenAll(tasks).ConfigureAwait(false); catch { break; }
} }
catch (Exception ex) { _logger.LogDebug(ex, "Health loop tick"); } }, ct);
try { await Task.Delay(interval).ConfigureAwait(false); } }
catch { break; }
}
});
} }
/// <summary> /// <summary>
@@ -546,6 +566,15 @@ public sealed partial class MainViewModel : ObservableObject
_license = _licenseService.GetCached(); _license = _licenseService.GetCached();
NotifyLicenseChanged(); NotifyLicenseChanged();
RebuildList(); 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));
} }
} }

View File

@@ -85,6 +85,17 @@ public sealed partial class SettingsViewModel : ObservableObject
= new(); = new();
public bool HasDocBackups => DocBackups.Count > 0; public bool HasDocBackups => DocBackups.Count > 0;
// ----- Health checks (bandeau de santé système) -----
/// <summary>
/// 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
/// <c>_config.HealthChecks.Checks</c> n'est écrasé qu'à <see cref="Save"/>),
/// pour que Cancel ne laisse pas de modifs partielles dans la config.
/// </summary>
public System.Collections.ObjectModel.ObservableCollection<HealthCheckRowViewModel> HealthChecks { get; }
= new();
public bool HasHealthChecks => HealthChecks.Count > 0;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } = public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available PSLauncher.Core.Localization.Strings.Available
.Select(t => new LanguageOption(t.Code, t.Name)) .Select(t => new LanguageOption(t.Code, t.Name))
@@ -193,6 +204,11 @@ public sealed partial class SettingsViewModel : ObservableObject
_docAutoDeploy = config.DocTool.AutoDeploy; _docAutoDeploy = config.DocTool.AutoDeploy;
_docMaxBackups = config.DocTool.MaxBackups; _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) // Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
_ = LoadReportBackupsAsync(); _ = LoadReportBackupsAsync();
_ = LoadDocBackupsAsync(); _ = LoadDocBackupsAsync();
@@ -327,6 +343,11 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.DocTool.AutoDeploy = DocAutoDeploy; _config.DocTool.AutoDeploy = DocAutoDeploy;
_config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups); _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); _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);
@@ -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] [RelayCommand]
private async Task RedeployDocAsync() private async Task RedeployDocAsync()
{ {
@@ -697,6 +765,66 @@ public sealed partial class ReportBackupViewModel : ObservableObject
private Task Revert() => _revertHandler(Info); private Task Revert() => _revertHandler(Info);
} }
/// <summary>
/// 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 <c>config.HealthChecks.Checks</c> n'a lieu qu'au Save.
/// </summary>
public sealed partial class HealthCheckRowViewModel : ObservableObject
{
public HealthCheckEntry Entry { get; private set; }
/// <summary>Re-publié à chaque édition pour rafraîchir le label dans la liste.</summary>
[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<HealthCheckRowViewModel> _onDelete;
public HealthCheckRowViewModel(HealthCheckEntry entry, Action<HealthCheckRowViewModel> 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);
}
/// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary> /// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>
public sealed partial class DocBackupViewModel : ObservableObject public sealed partial class DocBackupViewModel : ObservableObject
{ {

View File

@@ -0,0 +1,246 @@
<Window x:Class="PSLauncher.App.Views.HealthCheckEditorDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.HealthEditorTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="540" Height="640"
MinWidth="480" MinHeight="540"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}"
ResizeMode="CanResize">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Header -->
<Border Grid.Row="0" Padding="24,20,24,12">
<TextBlock x:Name="TitleText"
Text="{x:Static loc:Strings.HealthEditorTitle}"
FontSize="20" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,0,24,12">
<StackPanel>
<!-- Nom -->
<TextBlock Text="{x:Static loc:Strings.HealthEditorName}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<TextBox x:Name="NameBox"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8" />
<TextBlock Text="{x:Static loc:Strings.HealthEditorNameHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,12" />
<!-- Type de check -->
<TextBlock Text="{x:Static loc:Strings.HealthEditorKind}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<RadioButton x:Name="KindProcess"
GroupName="Kind"
Content="{x:Static loc:Strings.HealthEditorKindProcess}"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,0,20,0"
Checked="OnKindChanged" />
<RadioButton x:Name="KindPing"
GroupName="Kind"
Content="{x:Static loc:Strings.HealthEditorKindPing}"
Foreground="{StaticResource Brush.Text.Primary}"
Checked="OnKindChanged" />
</StackPanel>
<!-- Cible (process name ou IP/host) -->
<TextBlock x:Name="TargetLabel"
Text="{x:Static loc:Strings.HealthEditorTargetProcess}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<TextBox x:Name="TargetBox"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock x:Name="TargetHint"
Text="{x:Static loc:Strings.HealthEditorTargetProcessHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,12" TextWrapping="Wrap" />
<!-- Refresh interval -->
<TextBlock Text="{x:Static loc:Strings.HealthEditorRefresh}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="RefreshMsBox"
Grid.Column="0"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8" />
<TextBlock Grid.Column="1" VerticalAlignment="Center"
Margin="8,0,0,0"
Text="ms"
Foreground="{StaticResource Brush.Text.Secondary}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.HealthEditorRefreshHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,12" TextWrapping="Wrap" />
<!-- Picto picker (grille d'emojis) -->
<TextBlock Text="{x:Static loc:Strings.HealthEditorIcon}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" CornerRadius="4" Padding="6">
<ItemsControl x:Name="IconPicker">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Click="OnIconClicked"
Tag="{Binding Emoji}"
Content="{Binding Emoji}"
FontSize="22"
Width="40" Height="40"
Margin="2"
BorderThickness="2"
Cursor="Hand">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#0F1117" />
<Setter Property="BorderBrush" Value="#2A2F3A" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border CornerRadius="4"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#1F2530" />
<Setter Property="BorderBrush" Value="#3B82F6" />
</Trigger>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="Background" Value="#1B2A40" />
<Setter Property="BorderBrush" Value="#3B82F6" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<TextBlock Text="{x:Static loc:Strings.HealthEditorIconHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,12" TextWrapping="Wrap" />
<!-- Section Avancé : seuils Ping. Visible uniquement si Kind=Ping. -->
<Expander x:Name="PingAdvanced"
Header="{x:Static loc:Strings.HealthEditorPingAdvanced}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontWeight="Bold"
Background="Transparent" BorderThickness="0"
Margin="0,4,0,0" IsExpanded="False"
Visibility="Collapsed">
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="16" Margin="0,8,0,0">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingWarn}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox x:Name="PingWarnBox"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8"
Width="120" HorizontalAlignment="Left" />
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingError}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox x:Name="PingErrorBox"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8"
Width="120" HorizontalAlignment="Left" />
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingTimeout}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox x:Name="PingTimeoutBox"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1" Padding="8"
Width="120" HorizontalAlignment="Left" />
</StackPanel>
</Border>
</Expander>
<TextBlock x:Name="ErrorText"
Foreground="#F87171"
FontSize="12"
Margin="0,12,0,0"
Visibility="Collapsed"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
Padding="20,12">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.ActionCancel}"
IsCancel="True"
Margin="0,0,12,0"
Click="OnCancel" />
<Button Style="{StaticResource AccentButton}"
Content="{x:Static loc:Strings.ActionSave}"
Padding="32,8"
IsDefault="True"
Click="OnSave" />
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,169 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
/// <summary>
/// Modal d'édition d'une entrée du bandeau de santé. Présenté soit pour création
/// (constructeur sans argument), soit pour modification (constructeur avec entry
/// existante). À la sauvegarde, applique les valeurs sur l'objet d'origine pour
/// que la liste parente n'ait rien à reconstruire.
///
/// Choix UI : icon picker en grille avec 28 emojis curatés couvrant les usages
/// VR/streaming/réseau (les checks typiques de PROSERVE). On évite la saisie
/// libre d'un emoji pour ne pas se retrouver avec des caractères qui ne rendent
/// pas bien dans la pill du bandeau.
/// </summary>
public partial class HealthCheckEditorDialog : Window
{
/// <summary>Emojis proposés dans le picker. Listés du plus probable au moins probable.</summary>
private static readonly string[] CuratedIcons =
{
"🎮", "🥽", "📡", "🌐", "📶", "🔌", "💻", "🖥",
"📷", "🎧", "🛡", "🔥", "⚙", "🚀", "🔧", "📦",
"🎯", "🏃", "🚨", "🔋", "💾", "🔍", "📊", "🖱",
"⌨", "🎚", "🟢", "🔵",
};
public HealthCheckEntry Entry { get; }
public bool Saved { get; private set; }
private readonly ObservableCollection<IconOption> _icons = new();
public HealthCheckEditorDialog() : this(new HealthCheckEntry
{
Name = "",
Icon = "🔌",
Kind = "Process",
Target = "",
RefreshIntervalMs = 5000,
}, isNew: true)
{
}
public HealthCheckEditorDialog(HealthCheckEntry entry, bool isNew = false)
{
Entry = entry;
InitializeComponent();
TitleText.Text = isNew ? Strings.HealthEditorTitleNew : Strings.HealthEditorTitle;
// Charge les valeurs courantes
NameBox.Text = entry.Name ?? "";
TargetBox.Text = entry.Target ?? "";
RefreshMsBox.Text = entry.RefreshIntervalMs.ToString();
var isPing = entry.Kind?.Equals("ping", System.StringComparison.OrdinalIgnoreCase) == true;
KindProcess.IsChecked = !isPing;
KindPing.IsChecked = isPing;
UpdateKindUi(isPing);
PingWarnBox.Text = (entry.PingWarnMs ?? 50).ToString();
PingErrorBox.Text = (entry.PingErrorMs ?? 200).ToString();
PingTimeoutBox.Text = (entry.PingTimeoutMs ?? 1000).ToString();
// Construit le picker, marque l'icône courante comme sélectionnée
var current = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
var iconList = CuratedIcons.ToList();
if (!iconList.Contains(current)) iconList.Insert(0, current); // garde une icône custom si déjà en config
foreach (var ic in iconList)
_icons.Add(new IconOption(ic) { IsSelected = ic == current });
IconPicker.ItemsSource = _icons;
}
private void UpdateKindUi(bool isPing)
{
if (isPing)
{
TargetLabel.Text = Strings.HealthEditorTargetPing;
TargetHint.Text = Strings.HealthEditorTargetPingHint;
PingAdvanced.Visibility = Visibility.Visible;
}
else
{
TargetLabel.Text = Strings.HealthEditorTargetProcess;
TargetHint.Text = Strings.HealthEditorTargetProcessHint;
PingAdvanced.Visibility = Visibility.Collapsed;
}
}
private void OnKindChanged(object sender, RoutedEventArgs e)
{
if (TargetLabel is null) return; // pendant l'init avant que tout soit chargé
UpdateKindUi(KindPing.IsChecked == true);
}
private void OnIconClicked(object sender, RoutedEventArgs e)
{
if (sender is not Button btn || btn.Tag is not string emoji) return;
foreach (var ic in _icons) ic.IsSelected = ic.Emoji == emoji;
}
private void OnSave(object sender, RoutedEventArgs e)
{
var name = NameBox.Text?.Trim() ?? "";
if (string.IsNullOrEmpty(name))
{
ShowError(Strings.HealthEditorErrorName);
return;
}
if (!int.TryParse(RefreshMsBox.Text?.Trim(), out var refreshMs) || refreshMs < 200)
{
ShowError(Strings.HealthEditorErrorRefresh);
return;
}
var selectedIcon = _icons.FirstOrDefault(i => i.IsSelected)?.Emoji ?? Entry.Icon;
Entry.Name = name;
Entry.Icon = string.IsNullOrEmpty(selectedIcon) ? "🔌" : selectedIcon;
Entry.Kind = KindPing.IsChecked == true ? "Ping" : "Process";
Entry.Target = TargetBox.Text?.Trim() ?? "";
Entry.RefreshIntervalMs = refreshMs;
if (Entry.Kind == "Ping")
{
Entry.PingWarnMs = TryParseNullableInt(PingWarnBox.Text);
Entry.PingErrorMs = TryParseNullableInt(PingErrorBox.Text);
Entry.PingTimeoutMs = TryParseNullableInt(PingTimeoutBox.Text);
}
Saved = true;
DialogResult = true;
Close();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
private void ShowError(string msg)
{
ErrorText.Text = msg;
ErrorText.Visibility = Visibility.Visible;
}
private static int? TryParseNullableInt(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
return int.TryParse(s.Trim(), out var v) ? v : null;
}
/// <summary>Élément du picker d'icônes — emoji + état sélectionné pour le data trigger.</summary>
public sealed partial class IconOption : ObservableObject
{
public string Emoji { get; }
[ObservableProperty]
private bool _isSelected;
public IconOption(string emoji) { Emoji = emoji; }
}
}

View File

@@ -648,6 +648,87 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Bandeau de santé système : liste éditable des checks -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsHealthChecks}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthAdd}"
Command="{Binding AddHealthCheckCommand}"
Padding="12,6" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsHealthChecksHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,8" TextWrapping="Wrap" />
<TextBlock Text="{x:Static loc:Strings.SettingsHealthEmpty}"
FontSize="12" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" TextWrapping="Wrap"
Visibility="{Binding HasHealthChecks, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding HealthChecks}"
Visibility="{Binding HasHealthChecks, Converter={StaticResource BoolToVisibility}}"
Margin="0,4,0,0">
<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="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding DisplayIcon}"
FontSize="20"
VerticalAlignment="Center"
Margin="2,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding DisplayDetail}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11"
FontFamily="Consolas" />
</StackPanel>
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthEdit}"
Command="{Binding EditCommand}"
Margin="0,0,6,0"
Padding="12,6" />
<Button Grid.Column="3"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthDelete}"
Command="{Binding DeleteCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Logs (path + open button) --> <!-- Logs (path + open button) -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -946,6 +946,109 @@ public static class Strings
$"تاريخ الإصدار: {date} • {size}" $"تاريخ الإصدار: {date} • {size}"
); );
// ==================== HEALTH BANNER ====================
public static string SettingsHealthChecks => T(
"BANDEAU DE SANTÉ SYSTÈME",
"SYSTEM HEALTH BANNER",
"系统健康状态栏",
"แถบสถานะระบบ",
"شريط حالة النظام"
);
public static string SettingsHealthChecksHint => T(
"Pings et processus surveillés sous la top bar (Library uniquement). Édité ici, sauvegardé dans config.json — survit à un auto-update.",
"Pings and processes monitored below the top bar (Library only). Edited here, saved to config.json — survives auto-updates.",
"在顶部栏下监控的 Ping 和进程(仅库视图)。在此编辑,保存到 config.json — 在自动更新后保留。",
"Ping และโปรเซสที่ติดตามใต้แถบบนสุด (เฉพาะ Library) แก้ไขที่นี่ บันทึกใน config.json — คงอยู่หลังการอัปเดตอัตโนมัติ",
"Ping والعمليات المراقَبة أسفل الشريط العلوي (المكتبة فقط). يُحرَّر هنا، ويُحفَظ في config.json — يبقى بعد التحديث التلقائي."
);
public static string SettingsHealthAdd => T(" Ajouter", " Add", " 添加", " เพิ่ม", " إضافة");
public static string SettingsHealthEdit => T("✎ Éditer", "✎ Edit", "✎ 编辑", "✎ แก้ไข", "✎ تحرير");
public static string SettingsHealthDelete => T("🗑 Supprimer", "🗑 Delete", "🗑 删除", "🗑 ลบ", "🗑 حذف");
public static string SettingsHealthEmpty => T(
"Aucun check configuré. Le bandeau ne s'affichera pas tant qu'aucune entrée n'aura été ajoutée.",
"No checks configured. The banner stays hidden until at least one entry is added.",
"未配置任何检查。在添加至少一项之前,状态栏将保持隐藏。",
"ยังไม่มีการตรวจสอบที่ตั้งค่าไว้ แถบจะถูกซ่อนจนกว่าจะมีการเพิ่มอย่างน้อยหนึ่งรายการ",
"لم يُهيَّأ أي فحص. سيبقى الشريط مخفياً حتى تتم إضافة إدخال واحد على الأقل."
);
public static string SettingsHealthDeleteConfirm(string name) => T(
$"Supprimer le check « {name} » ?",
$"Delete check « {name} »?",
$"删除检查 \"{name}\"",
$"ลบการตรวจสอบ « {name} »?",
$"حذف الفحص « {name} »؟"
);
public static string HealthEditorTitle => T("Éditer le check", "Edit check", "编辑检查", "แก้ไขการตรวจสอบ", "تحرير الفحص");
public static string HealthEditorTitleNew => T("Nouveau check", "New check", "新建检查", "การตรวจสอบใหม่", "فحص جديد");
public static string HealthEditorName => T("NOM", "NAME", "名称", "ชื่อ", "الاسم");
public static string HealthEditorNameHint => T(
"Libellé court affiché dans la pill du bandeau, ex. « SteamVR ».",
"Short label shown in the banner pill, e.g. \"SteamVR\".",
"状态栏胶囊中显示的简短标签,例如 \"SteamVR\"。",
"ชื่อย่อที่แสดงในป้ายของแถบ เช่น \"SteamVR\"",
"تسمية قصيرة تظهر في الشريط، مثل \"SteamVR\"."
);
public static string HealthEditorKind => T("TYPE DE VÉRIFICATION", "CHECK TYPE", "检查类型", "ประเภทการตรวจสอบ", "نوع الفحص");
public static string HealthEditorKindProcess => T("Processus (Windows)", "Process (Windows)", "进程 (Windows)", "โปรเซส (Windows)", "عملية (Windows)");
public static string HealthEditorKindPing => T("Ping (réseau ICMP)", "Ping (ICMP network)", "Ping (ICMP 网络)", "Ping (เครือข่าย ICMP)", "Ping (شبكة ICMP)");
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية");
public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");
public static string HealthEditorTargetProcessHint => T(
"Nom de l'exécutable sans .exe, ex. « vrserver » ou « HtcConnectionUtility ». Insensible à la casse.",
"Executable name without .exe, e.g. \"vrserver\" or \"HtcConnectionUtility\". Case-insensitive.",
"可执行文件名(不含 .exe例如 \"vrserver\" 或 \"HtcConnectionUtility\"。不区分大小写。",
"ชื่อไฟล์ปฏิบัติการโดยไม่มี .exe เช่น \"vrserver\" หรือ \"HtcConnectionUtility\" ไม่คำนึงถึงตัวพิมพ์",
"اسم الملف التنفيذي بدون .exe، مثل \"vrserver\" أو \"HtcConnectionUtility\". غير حساس لحالة الأحرف."
);
public static string HealthEditorTargetPingHint => T(
"Adresse IP (ex. 192.168.1.42) ou hostname résolvable. Vide = check ignoré (statut Inconnu).",
"IP address (e.g. 192.168.1.42) or resolvable hostname. Empty = check skipped (Unknown status).",
"IP 地址(例如 192.168.1.42)或可解析的主机名。空 = 跳过检查(未知状态)。",
"ที่อยู่ IP (เช่น 192.168.1.42) หรือชื่อโฮสต์ที่แก้ได้ ว่างเปล่า = ข้ามการตรวจสอบ (สถานะไม่ทราบ)",
"عنوان IP (مثل 192.168.1.42) أو اسم مضيف قابل للحل. فارغ = تخطّي الفحص (الحالة غير معروفة)."
);
public static string HealthEditorRefresh => T("INTERVALLE DE RAFRAÎCHISSEMENT", "REFRESH INTERVAL", "刷新间隔", "ช่วงเวลารีเฟรช", "فاصل التحديث");
public static string HealthEditorRefreshHint => T(
"Délai entre 2 vérifications, en millisecondes. Process ~1 ms (1000 OK) ; Ping = round-trip réseau (5000-10000 conseillé). Min 200 ms.",
"Delay between 2 checks, in milliseconds. Process ~1 ms (1000 OK); Ping = network round-trip (5000-10000 recommended). Min 200 ms.",
"两次检查之间的延迟(毫秒)。进程约 1 毫秒1000 即可Ping = 网络往返(建议 5000-10000。最小 200 毫秒。",
"ระยะเวลาระหว่าง 2 การตรวจสอบเป็นมิลลิวินาที โปรเซส ~1 ms (1000 OK); Ping = round-trip เครือข่าย (แนะนำ 5000-10000). ขั้นต่ำ 200 ms.",
"التأخير بين فحصين بالمللي ثانية. عملية ~1 ms (1000 جيدة)؛ Ping = ذهاب وعودة الشبكة (يوصى 5000-10000). الحد الأدنى 200 ms."
);
public static string HealthEditorIcon => T("PICTOGRAMME", "ICON", "图标", "ไอคอน", "أيقونة");
public static string HealthEditorIconHint => T(
"Affiché à gauche du nom dans la pill. Choisis-en un dans la liste.",
"Displayed to the left of the name in the pill. Pick one from the list.",
"显示在胶囊中名称的左侧。从列表中选择一个。",
"แสดงทางซ้ายของชื่อในป้าย เลือกหนึ่งจากรายการ",
"تُعرض على يسار الاسم في الشارة. اختر واحدةً من القائمة."
);
public static string HealthEditorPingAdvanced => T(
"Avancé — seuils Ping",
"Advanced — Ping thresholds",
"高级 — Ping 阈值",
"ขั้นสูง — เกณฑ์ Ping",
"متقدم — حدود Ping"
);
public static string HealthEditorPingWarn => T("Warning ms — RTT au-dessus = orange", "Warning ms — RTT above = amber", "警告 ms — 高于此值 = 橙色", "Warning ms — RTT เกินค่านี้ = ส้ม", "تحذير ms — RTT فوق ذلك = برتقالي");
public static string HealthEditorPingError => T("Error ms — RTT au-dessus = rouge", "Error ms — RTT above = red", "错误 ms — 高于此值 = 红色", "Error ms — RTT เกินค่านี้ = แดง", "خطأ ms — RTT فوق ذلك = أحمر");
public static string HealthEditorPingTimeout => T("Timeout ms — abandon de la requête", "Timeout ms — abort the request", "超时 ms — 中止请求", "Timeout ms — ยกเลิกคำขอ", "مهلة ms — إلغاء الطلب");
public static string HealthEditorErrorName => T(
"Le nom est requis.",
"Name is required.",
"需要名称。",
"ต้องระบุชื่อ",
"الاسم مطلوب."
);
public static string HealthEditorErrorRefresh => T(
"Intervalle invalide. Saisis un entier ≥ 200 (millisecondes).",
"Invalid interval. Enter an integer ≥ 200 (milliseconds).",
"间隔无效。请输入 ≥ 200 的整数(毫秒)。",
"ช่วงเวลาไม่ถูกต้อง ใส่จำนวนเต็ม ≥ 200 (มิลลิวินาที)",
"فاصل غير صالح. أدخل عدداً صحيحاً ≥ 200 (مللي ثانية)."
);
// ==================== COPYRIGHT ==================== // ==================== COPYRIGHT ====================
public static string Copyright => T( public static string Copyright => T(
"© 2026 ASTERION VR — Tous droits réservés", "© 2026 ASTERION VR — Tous droits réservés",

View File

@@ -208,6 +208,14 @@ public sealed class HealthCheckEntry
/// </summary> /// </summary>
public string Target { get; set; } = ""; public string Target { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
public int RefreshIntervalMs { get; set; } = 5000;
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary> /// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary>
public int? PingWarnMs { get; set; } = 50; public int? PingWarnMs { get; set; } = 50;