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

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

View File

@@ -74,6 +74,14 @@ public sealed partial class MainViewModel : ObservableObject
public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
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>
/// 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
}
/// <summary>
/// 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
/// <see cref="HealthCheckEntry.RefreshIntervalMs"/>. 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).
/// </summary>
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);
}
}
/// <summary>
@@ -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));
}
}

View File

@@ -85,6 +85,17 @@ public sealed partial class SettingsViewModel : ObservableObject
= new();
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; } =
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);
}
/// <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>
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>
</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) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"