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:
@@ -17,6 +17,7 @@ using PSLauncher.Core.Licensing;
|
||||
using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
@@ -190,6 +191,8 @@ public partial class App : Application
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
|
||||
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
|
||||
|
||||
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
services.AddSingleton<MainViewModel>();
|
||||
services.AddSingleton<MainWindow>();
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.20.0</Version>
|
||||
<AssemblyVersion>0.20.0.0</AssemblyVersion>
|
||||
<FileVersion>0.20.0.0</FileVersion>
|
||||
<Version>0.21.0</Version>
|
||||
<AssemblyVersion>0.21.0.0</AssemblyVersion>
|
||||
<FileVersion>0.21.0.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
92
src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs
Normal file
92
src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Windows.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Représente un indicateur visuel (pill colorée) dans le bandeau de santé
|
||||
/// système. Mis à jour périodiquement par <see cref="MainViewModel"/> à
|
||||
/// partir du résultat de <see cref="ISystemHealthService.RunCheckAsync"/>.
|
||||
/// </summary>
|
||||
public sealed partial class HealthIndicatorViewModel : ObservableObject
|
||||
{
|
||||
public HealthCheckEntry Entry { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusBrush))]
|
||||
[NotifyPropertyChangedFor(nameof(BorderBrush))]
|
||||
[NotifyPropertyChangedFor(nameof(IconForeground))]
|
||||
[NotifyPropertyChangedFor(nameof(Tooltip))]
|
||||
private HealthSeverity _severity = HealthSeverity.Unknown;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Tooltip))]
|
||||
private string _detail = "Pas encore vérifié";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Tooltip))]
|
||||
private DateTime _lastCheckedUtc = DateTime.MinValue;
|
||||
|
||||
public string Name => Entry.Name;
|
||||
public string Icon => Entry.Icon;
|
||||
|
||||
public Brush StatusBrush => Severity switch
|
||||
{
|
||||
// Pill background : un peu transparent pour que le bandeau reste discret
|
||||
HealthSeverity.Ok => new SolidColorBrush(Color.FromArgb(0x40, 0x16, 0xA3, 0x4A)), // vert
|
||||
HealthSeverity.Warning => new SolidColorBrush(Color.FromArgb(0x50, 0xF5, 0x9E, 0x0B)), // orange
|
||||
HealthSeverity.Error => new SolidColorBrush(Color.FromArgb(0x50, 0xEF, 0x44, 0x44)), // rouge
|
||||
_ => new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x90)), // gris
|
||||
};
|
||||
|
||||
public Brush BorderBrush => Severity switch
|
||||
{
|
||||
HealthSeverity.Ok => new SolidColorBrush(Color.FromRgb(0x16, 0xA3, 0x4A)),
|
||||
HealthSeverity.Warning => new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)),
|
||||
HealthSeverity.Error => new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
|
||||
_ => new SolidColorBrush(Color.FromRgb(0x60, 0x60, 0x70)),
|
||||
};
|
||||
|
||||
public Brush IconForeground => Severity switch
|
||||
{
|
||||
HealthSeverity.Ok => new SolidColorBrush(Color.FromRgb(0x4A, 0xDE, 0x80)),
|
||||
HealthSeverity.Warning => new SolidColorBrush(Color.FromRgb(0xFB, 0xBF, 0x24)),
|
||||
HealthSeverity.Error => new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
|
||||
_ => new SolidColorBrush(Color.FromRgb(0xA0, 0xA0, 0xA8)),
|
||||
};
|
||||
|
||||
public string Tooltip
|
||||
{
|
||||
get
|
||||
{
|
||||
var status = Severity switch
|
||||
{
|
||||
HealthSeverity.Ok => "✓ OK",
|
||||
HealthSeverity.Warning => "⚠ Limitation détectée",
|
||||
HealthSeverity.Error => "⛔ Problème",
|
||||
_ => "❓ Non vérifié",
|
||||
};
|
||||
var ts = LastCheckedUtc == DateTime.MinValue
|
||||
? ""
|
||||
: $"\n\nDernière vérif : {LastCheckedUtc.ToLocalTime():HH:mm:ss}";
|
||||
var kindLabel = Entry.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true
|
||||
? $"Ping {Entry.Target}"
|
||||
: $"Processus {Entry.Target}";
|
||||
return $"{Entry.Icon} {Entry.Name} — {status}\n{kindLabel}\n\n{Detail}{ts}";
|
||||
}
|
||||
}
|
||||
|
||||
public HealthIndicatorViewModel(HealthCheckEntry entry)
|
||||
{
|
||||
Entry = entry;
|
||||
}
|
||||
|
||||
public void Apply(HealthResult result)
|
||||
{
|
||||
Severity = result.Severity;
|
||||
Detail = result.Detail;
|
||||
LastCheckedUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ using PSLauncher.Core.Licensing;
|
||||
using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
@@ -45,6 +46,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private readonly IDatabaseMigrationService _migrationService;
|
||||
private readonly IReportToolDeployer _reportDeployer;
|
||||
private readonly IDocToolDeployer _docDeployer;
|
||||
private readonly ISystemHealthService _healthService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<MainViewModel> _logger;
|
||||
|
||||
@@ -63,6 +65,15 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Indicateurs affichés dans le bandeau de santé sous la top bar : pings,
|
||||
/// processus requis (SteamVR, Vive Streaming, etc.). Liste construite depuis
|
||||
/// <see cref="HealthChecksConfig.Checks"/> au démarrage. Refresh périodique
|
||||
/// piloté par <see cref="StartHealthLoop"/>.
|
||||
/// </summary>
|
||||
public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
|
||||
public bool HasHealthIndicators => HealthIndicators.Count > 0;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
|
||||
[NotifyPropertyChangedFor(nameof(HasOtherVersions))]
|
||||
@@ -236,6 +247,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
IDatabaseMigrationService migrationService,
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
ISystemHealthService healthService,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<MainViewModel> logger)
|
||||
{
|
||||
@@ -253,6 +265,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_migrationService = migrationService;
|
||||
_reportDeployer = reportDeployer;
|
||||
_docDeployer = docDeployer;
|
||||
_healthService = healthService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
|
||||
@@ -261,6 +274,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_license = _licenseService.GetCached();
|
||||
|
||||
RebuildList();
|
||||
InitHealthIndicators();
|
||||
|
||||
// Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
|
||||
_ = Task.Run(async () =>
|
||||
@@ -275,6 +289,62 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); }
|
||||
});
|
||||
|
||||
// Boucle périodique des health checks. Ne tourne que si la config en
|
||||
// contient au moins un. Premier passage immédiat puis re-check tous les
|
||||
// RefreshIntervalSeconds.
|
||||
StartHealthLoop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit la collection observable des indicateurs depuis la config et
|
||||
/// notifie HasHealthIndicators pour que la UI cache le bandeau si la liste
|
||||
/// est vide. Idempotent — appelée au startup uniquement, pas à chaque refresh.
|
||||
/// </summary>
|
||||
private void InitHealthIndicators()
|
||||
{
|
||||
HealthIndicators.Clear();
|
||||
foreach (var entry in _config.HealthChecks.Checks)
|
||||
HealthIndicators.Add(new HealthIndicatorViewModel(entry));
|
||||
OnPropertyChanged(nameof(HasHealthIndicators));
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
private void StartHealthLoop()
|
||||
{
|
||||
if (HealthIndicators.Count == 0) return;
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(2, _config.HealthChecks.RefreshIntervalSeconds));
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = HealthIndicators.ToList();
|
||||
var tasks = snapshot.Select(async vm =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Health loop tick"); }
|
||||
try { await Task.Delay(interval).ConfigureAwait(false); }
|
||||
catch { break; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -198,16 +198,15 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||
<RowDefinition Height="Auto" /> <!-- Health banner -->
|
||||
<RowDefinition Height="*" /> <!-- Body (sidebar + content) -->
|
||||
<RowDefinition Height="Auto" /> <!-- Footer -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Background image : limité à la zone content (Row 1, Col 1) pour ne pas
|
||||
passer derrière la sidebar ni le footer. ImageBrush avec AlignmentX=Right
|
||||
+ AlignmentY=Bottom : si le ratio fenêtre force un crop, on rogne en
|
||||
haut/à gauche — le logo ASTERION VR en bas-droite reste TOUJOURS visible. -->
|
||||
<Rectangle Grid.Row="1" Grid.Column="1" IsHitTestVisible="False">
|
||||
<!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
|
||||
passer derrière la sidebar, le banner ou le footer. -->
|
||||
<Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
|
||||
<Rectangle.Fill>
|
||||
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
||||
Stretch="UniformToFill"
|
||||
@@ -216,10 +215,7 @@
|
||||
Opacity="0.25" />
|
||||
</Rectangle.Fill>
|
||||
</Rectangle>
|
||||
<!-- Voile bleuté (~8% opacité) posé sur l'image pour donner la teinte
|
||||
navy/cyan d'ASTERION VR. IsHitTestVisible=False pour ne pas bloquer
|
||||
les clics qui visent le content area. -->
|
||||
<Rectangle Grid.Row="1" Grid.Column="1"
|
||||
<Rectangle Grid.Row="2" Grid.Column="1"
|
||||
Fill="{StaticResource Brush.Bg.BlueTint}"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
@@ -345,10 +341,54 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============== Content area (Row 1, Col 1, swap par CurrentPage) ==============
|
||||
<!-- ============== Health banner (Row 1, ColSpan 2) ==============
|
||||
Pills colorées indiquant l'état des dépendances système (SteamVR,
|
||||
Vive Streaming, ping casque…). Cachée si la config n'a aucun check.
|
||||
Tooltip détaillé au survol de chaque pill. -->
|
||||
<Border Grid.Row="1" Grid.ColumnSpan="2"
|
||||
Background="{StaticResource Brush.Bg.Sidebar}"
|
||||
BorderBrush="{StaticResource Brush.Border}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="16,8"
|
||||
Visibility="{Binding HasHealthIndicators, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl ItemsSource="{Binding HealthIndicators}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{Binding StatusBrush}"
|
||||
BorderBrush="{Binding BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="10,4"
|
||||
Margin="0,0,8,0"
|
||||
ToolTip="{Binding Tooltip}"
|
||||
ToolTipService.InitialShowDelay="200"
|
||||
ToolTipService.ShowDuration="20000">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Icon}"
|
||||
FontSize="14"
|
||||
Foreground="{Binding IconForeground}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Name}"
|
||||
Margin="6,0,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<!-- ============== Content area (Row 2, Col 1, swap par CurrentPage) ==============
|
||||
Superpose plusieurs blocs (Library / Report / Documentation) gérés par
|
||||
leur Visibility liée à CurrentPage. -->
|
||||
<Grid Grid.Row="1" Grid.Column="1">
|
||||
<Grid Grid.Row="2" Grid.Column="1">
|
||||
|
||||
<!-- ====================== LIBRARY PAGE ====================== -->
|
||||
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
|
||||
@@ -611,10 +651,10 @@
|
||||
<!-- ============== END Content area ============== -->
|
||||
|
||||
<!-- ============== Left sidebar : nav buttons ==============
|
||||
Row 1 + Row 2 (RowSpan=2) → la sidebar s'étend depuis le bas du
|
||||
top bar jusqu'en bas de la fenêtre, peu importe la visibilité du
|
||||
footer ou de la taille de la zone content. -->
|
||||
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0"
|
||||
Row 2 + RowSpan=2 → la sidebar s'étend depuis le bas du health
|
||||
banner jusqu'en bas de la fenêtre, couvrant content + footer.
|
||||
(Row 0 = top bar, Row 1 = health banner full-width.) -->
|
||||
<Border Grid.Row="2" Grid.RowSpan="2" Grid.Column="0"
|
||||
Background="#0A0A0E"
|
||||
BorderBrush="{StaticResource Brush.Border}"
|
||||
BorderThickness="0,0,1,0">
|
||||
@@ -647,9 +687,10 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Footer : Row 2, Col 1 → cantonné à la colonne content, sous la zone
|
||||
Library/Report/Doc. La sidebar (RowSpan=2) le contourne par sa colonne. -->
|
||||
<Border Grid.Row="2" Grid.Column="1"
|
||||
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
|
||||
Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
|
||||
par sa colonne. -->
|
||||
<Border Grid.Row="3" Grid.Column="1"
|
||||
Background="{StaticResource Brush.Bg.Footer}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||
Padding="20,10"
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ public sealed class LocalConfig
|
||||
/// </summary>
|
||||
public DocToolConfig DocTool { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Surveillance d'état système affichée en bandeau sous la top bar
|
||||
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
|
||||
/// Chaque check a un statut Vert (OK) / Orange (warn, ex. ping élevé) / Rouge
|
||||
/// (KO). Tooltip détaillé au survol.
|
||||
/// </summary>
|
||||
public HealthChecksConfig HealthChecks { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
|
||||
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
|
||||
@@ -142,6 +150,74 @@ public sealed class DocToolConfig
|
||||
public int MaxBackups { get; set; } = 3;
|
||||
}
|
||||
|
||||
public sealed class HealthChecksConfig
|
||||
{
|
||||
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>
|
||||
public int RefreshIntervalSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Liste des checks à effectuer. Defaults = exemples typiques setup VR
|
||||
/// (SteamVR + Vive Business Streaming + ping casque). L'utilisateur édite
|
||||
/// la liste via <c>%LocalAppData%\PSLauncher\config.json</c> pour ajuster
|
||||
/// les noms de processus, IPs et seuils ; un éditeur Settings est prévu
|
||||
/// en v2 si le besoin est récurrent.
|
||||
/// </summary>
|
||||
public List<HealthCheckEntry> Checks { get; set; } = new()
|
||||
{
|
||||
new HealthCheckEntry
|
||||
{
|
||||
Name = "SteamVR",
|
||||
Icon = "🎮",
|
||||
Kind = "Process",
|
||||
Target = "vrserver",
|
||||
},
|
||||
new HealthCheckEntry
|
||||
{
|
||||
Name = "Vive Business Streaming",
|
||||
Icon = "📡",
|
||||
Kind = "Process",
|
||||
Target = "HtcConnectionUtility",
|
||||
},
|
||||
new HealthCheckEntry
|
||||
{
|
||||
Name = "Casque VR",
|
||||
Icon = "🥽",
|
||||
Kind = "Ping",
|
||||
Target = "", // À remplir avec l'IP réseau du casque ; vide = check désactivé
|
||||
PingWarnMs = 50,
|
||||
PingErrorMs = 200,
|
||||
PingTimeoutMs = 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class HealthCheckEntry
|
||||
{
|
||||
/// <summary>Libellé affiché dans le bandeau, ex. "SteamVR".</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Pictogramme emoji affiché à côté du nom, ex. "🎮", "📡", "🥽".</summary>
|
||||
public string Icon { get; set; } = "🔌";
|
||||
|
||||
/// <summary>"Process" ou "Ping" (case-insensitive).</summary>
|
||||
public string Kind { get; set; } = "Process";
|
||||
|
||||
/// <summary>
|
||||
/// Selon Kind : nom du processus (sans .exe) pour Process,
|
||||
/// IP/hostname pour Ping. Vide = check ignoré (statut Unknown).
|
||||
/// </summary>
|
||||
public string Target { get; set; } = "";
|
||||
|
||||
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary>
|
||||
public int? PingWarnMs { get; set; } = 50;
|
||||
|
||||
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Error (Rouge). Default 200 ms.</summary>
|
||||
public int? PingErrorMs { get; set; } = 200;
|
||||
|
||||
/// <summary>Pour Ping : timeout de la requête ICMP. Default 1 s.</summary>
|
||||
public int? PingTimeoutMs { get; set; } = 1000;
|
||||
}
|
||||
|
||||
public sealed class LicenseConfig
|
||||
{
|
||||
public string? EncryptedKey { get; set; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>PS_Launcher.Updater</AssemblyName>
|
||||
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
||||
<Version>0.20.0</Version>
|
||||
<Version>0.21.0</Version>
|
||||
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
||||
<Company>ASTERION VR</Company>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
|
||||
Reference in New Issue
Block a user