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

@@ -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>();

View File

@@ -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>

View 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;
}
}

View File

@@ -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>

View File

@@ -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"