v0.21.0 — System health banner with colored indicators + tooltips

New row between top bar and content showing pill-shaped status indicators
for the system dependencies that PROSERVE needs (SteamVR, Vive Business
Streaming process, VR headset reachable on the network, etc.). Each pill
is colored : 🟢 OK, 🟠 limitation détectée (e.g. ping élevé), 🔴 KO,
gris = non configuré. Hover → tooltip with full check kind, target,
last RTT/process count and timestamp.

Architecture
- LocalConfig.HealthChecksConfig : list of HealthCheckEntry (Kind=Ping
  or Process, Target=IP/host or process name, ping thresholds in ms,
  refresh interval in s).
- ISystemHealthService + SystemHealthService : ping via
  System.Net.NetworkInformation.Ping (no admin required), process
  lookup via System.Diagnostics.Process.GetProcessesByName. Returns
  HealthResult { Severity, Detail }.
- HealthIndicatorViewModel : observable wrapper per entry with
  Severity-driven Brushes (background pill, border, icon colour) and
  composed Tooltip text.
- MainViewModel.InitHealthIndicators + StartHealthLoop : populates
  ObservableCollection<HealthIndicatorViewModel> from config and runs
  parallel checks every RefreshIntervalSeconds (default 10s).
- MainWindow.xaml : new Row 1 (between top bar and body) hosting an
  ItemsControl bound to HealthIndicators. Row collapses cleanly if the
  list is empty (HasHealthIndicators=false).

Defaults shipped (all editable in %LocalAppData%\PSLauncher\config.json)
- 🎮 SteamVR (Process : vrserver)
- 📡 Vive Business Streaming (Process : HtcConnectionUtility)
- 🥽 Casque VR (Ping : empty, user fills in the headset IP)

The Settings UI editor for managing the list is intentionally deferred
to a future iteration — config.json edit is enough to start using it.

Versions bumped to 0.21.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:44:15 +02:00
parent 30eceaea2c
commit 60358a6cf5
10 changed files with 450 additions and 26 deletions

View File

@@ -0,0 +1,32 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Health;
/// <summary>
/// Effectue les vérifications de santé système configurées dans
/// <see cref="HealthChecksConfig"/> : ping IP, processus tournant, etc.
/// </summary>
public interface ISystemHealthService
{
/// <summary>
/// Exécute un check unique en async (ping ou check de process). Le résultat
/// porte la sévérité + un détail textuel destiné au tooltip de la UI.
/// </summary>
Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct);
}
public sealed record HealthResult(
HealthSeverity Severity,
string Detail);
/// <summary>
/// Sévérité d'un check. <see cref="Unknown"/> = check pas configuré
/// (Target vide), affiché en gris.
/// </summary>
public enum HealthSeverity
{
Unknown,
Ok,
Warning,
Error,
}

View File

@@ -0,0 +1,110 @@
using System.Net.NetworkInformation;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
// NB : on n'importe PAS System.Diagnostics car le namespace PSLauncher.Core.Process
// existe déjà et provoque une ambiguïté avec System.Diagnostics.Process.
// On qualifie complètement chaque usage pour rester sans ambiguïté.
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.Health;
/// <summary>
/// Implémentation : Ping ICMP via <see cref="Ping"/>, et listing de processus
/// via <see cref="Process.GetProcessesByName(string)"/>. Pas de privilèges
/// administrateur requis (le ping classique ICMP fonctionne en user-mode sur
/// Windows 10+ ; pas besoin du raw socket).
/// </summary>
public sealed class SystemHealthService : ISystemHealthService
{
private readonly ILogger<SystemHealthService> _logger;
public SystemHealthService(ILogger<SystemHealthService> logger)
{
_logger = logger;
}
public async Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(entry.Target))
return new HealthResult(HealthSeverity.Unknown, "Non configuré (Target vide)");
try
{
return entry.Kind?.Trim().ToLowerInvariant() switch
{
"ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false),
"process" => CheckProcess(entry),
_ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"),
};
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogDebug(ex, "Health check {Name} ({Kind} {Target}) failed", entry.Name, entry.Kind, entry.Target);
return new HealthResult(HealthSeverity.Error, $"Exception : {ex.GetType().Name} — {ex.Message}");
}
}
private static async Task<HealthResult> CheckPingAsync(HealthCheckEntry entry, CancellationToken ct)
{
using var ping = new Ping();
var timeout = entry.PingTimeoutMs ?? 1000;
PingReply reply;
try
{
reply = await ping.SendPingAsync(entry.Target, timeout).WaitAsync(ct).ConfigureAwait(false);
}
catch (PingException pex)
{
return new HealthResult(HealthSeverity.Error, $"Ping {entry.Target} : DNS introuvable ou réseau inaccessible ({pex.InnerException?.Message ?? pex.Message})");
}
if (reply.Status != IPStatus.Success)
return new HealthResult(HealthSeverity.Error,
$"Ping {entry.Target} : pas de réponse ({reply.Status})");
var rtt = reply.RoundtripTime;
var warn = entry.PingWarnMs ?? 50;
var err = entry.PingErrorMs ?? 200;
if (rtt > err)
return new HealthResult(HealthSeverity.Error,
$"Ping {entry.Target} : {rtt} ms (seuil rouge {err} ms)");
if (rtt > warn)
return new HealthResult(HealthSeverity.Warning,
$"Ping {entry.Target} : {rtt} ms (seuil orange {warn} ms)");
return new HealthResult(HealthSeverity.Ok,
$"Ping {entry.Target} : {rtt} ms");
}
private static HealthResult CheckProcess(HealthCheckEntry entry)
{
// Process.GetProcessesByName accepte le nom sans extension .exe
var name = entry.Target.Trim();
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
name = name[..^4];
SysProcess[] procs;
try
{
procs = SysProcess.GetProcessesByName(name);
}
catch (Exception ex)
{
return new HealthResult(HealthSeverity.Error, $"Lookup processus échoué : {ex.Message}");
}
try
{
if (procs.Length == 0)
return new HealthResult(HealthSeverity.Error,
$"Processus « {name}.exe » non lancé");
return new HealthResult(HealthSeverity.Ok,
$"« {name}.exe » est en cours d'exécution ({procs.Length} instance{(procs.Length > 1 ? "s" : "")})");
}
finally
{
// Process objects portent un handle Win32 à libérer
foreach (var p in procs) p.Dispose();
}
}
}