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:
32
src/PSLauncher.Core/Health/ISystemHealthService.cs
Normal file
32
src/PSLauncher.Core/Health/ISystemHealthService.cs
Normal 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,
|
||||
}
|
||||
110
src/PSLauncher.Core/Health/SystemHealthService.cs
Normal file
110
src/PSLauncher.Core/Health/SystemHealthService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user