From 3d474bb927d1f08aeef5bb36aee720685ee7588e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 18:42:19 +0200 Subject: [PATCH] feat(app): add overview and main window view models --- .../ViewModels/MainWindowViewModel.cs | 176 ++++++++++++++++++ .../ViewModels/OverviewViewModel.cs | 42 +++++ 2 files changed, 218 insertions(+) create mode 100644 src/Vantage.App/ViewModels/MainWindowViewModel.cs create mode 100644 src/Vantage.App/ViewModels/OverviewViewModel.cs diff --git a/src/Vantage.App/ViewModels/MainWindowViewModel.cs b/src/Vantage.App/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..7bf6dac --- /dev/null +++ b/src/Vantage.App/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,176 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Vantage.Core.Alerts; +using Vantage.Core.Configuration; +using Vantage.Core.Docker; +using Vantage.Core.Health; +using Vantage.Core.History; +using Vantage.Core.Metrics; +using Vantage.Core.Models; + +namespace Vantage.App.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly IMetricsProvider metricsProvider; + private readonly IHealthChecker healthChecker; + private readonly IConfigStore configStore; + private readonly IMetricsStore? metricsStore; + private readonly AlertEngine? alertEngine; + + private VantageConfig config = new(); + private CancellationTokenSource? monitorCts; + + [ObservableProperty] private HostViewModel? selectedHost; + [ObservableProperty] private string statusLine = "Idle"; + [ObservableProperty] private bool isMonitoring; + [ObservableProperty] private int firingAlertCount; + + public MainWindowViewModel( + IMetricsProvider metricsProvider, + IDockerProvider dockerProvider, + IHealthChecker healthChecker, + IConfigStore configStore, + IMetricsStore? metricsStore = null, + AlertEngine? alertEngine = null) + { + this.metricsProvider = metricsProvider; + this.healthChecker = healthChecker; + this.configStore = configStore; + this.metricsStore = metricsStore; + this.alertEngine = alertEngine; + Docker = new DockerViewModel(dockerProvider); + Overview = new OverviewViewModel(Hosts); + } + + public ObservableCollection Hosts { get; } = new(); + public ObservableCollection HealthChecks { get; } = new(); + public ObservableCollection Alerts { get; } = new(); + public DockerViewModel Docker { get; } + public OverviewViewModel Overview { get; } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + config = await configStore.LoadAsync(cancellationToken).ConfigureAwait(false); + if (config.Hosts.Count == 0 && config.HealthChecks.Count == 0) + config = DemoData.Build(); + + Hosts.Clear(); + foreach (var host in config.Hosts) + Hosts.Add(new HostViewModel(host, config.HistoryLength)); + + HealthChecks.Clear(); + foreach (var check in config.HealthChecks) + HealthChecks.Add(new HealthCheckViewModel(check, healthChecker)); + + if (alertEngine is not null) + { + alertEngine.SetRules(config.AlertRules); + if (!string.IsNullOrWhiteSpace(config.AlertWebhookUrl)) + alertEngine.AddSink(new WebhookAlertSink(new HttpClient(), new Uri(config.AlertWebhookUrl))); + } + + SelectedHost = Hosts.FirstOrDefault(); + Overview.Refresh(); + } + + partial void OnSelectedHostChanged(HostViewModel? value) + { + Docker.SetHost(value?.Config); + } + + [RelayCommand] + public async Task RefreshSelectedHostAsync(CancellationToken cancellationToken) + { + var host = SelectedHost; + if (host is null) + return; + + try + { + var metrics = await metricsProvider.GetMetricsAsync(host.Config, cancellationToken).ConfigureAwait(false); + host.ApplyMetrics(metrics); + StatusLine = $"Updated {host.Name} at {metrics.CollectedAt.ToLocalTime():HH:mm:ss}"; + + if (metricsStore is not null) + await metricsStore.RecordHostMetricsAsync(host.Config.Id, metrics, cancellationToken).ConfigureAwait(false); + + if (alertEngine is not null) + { + await alertEngine.IngestAsync(host.Config.Id, metrics, metrics.CollectedAt, cancellationToken).ConfigureAwait(false); + UpdateAlerts(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + host.ApplyError(ex.Message); + StatusLine = $"{host.Name}: {ex.Message}"; + } + + Overview.Refresh(); + } + + private void UpdateAlerts() + { + if (alertEngine is null) + return; + + Alerts.Clear(); + foreach (var status in alertEngine.Statuses()) + Alerts.Add(status); + + FiringAlertCount = alertEngine.ActiveAlerts().Count; + Overview.FiringAlerts = FiringAlertCount; + } + + [RelayCommand] + public async Task RunAllHealthAsync(CancellationToken cancellationToken) + { + var checks = HealthChecks.Select(check => check.RunAsync(cancellationToken)); + await Task.WhenAll(checks).ConfigureAwait(false); + } + + public void StartMonitoring() + { + if (IsMonitoring) + return; + + monitorCts = new CancellationTokenSource(); + IsMonitoring = true; + _ = MonitorLoopAsync(monitorCts.Token); + } + + public void StopMonitoring() + { + monitorCts?.Cancel(); + monitorCts?.Dispose(); + monitorCts = null; + IsMonitoring = false; + } + + private async Task MonitorLoopAsync(CancellationToken cancellationToken) + { + var interval = TimeSpan.FromSeconds(Math.Max(1, config.RefreshIntervalSeconds)); + using var timer = new PeriodicTimer(interval); + + await RefreshSelectedHostAsync(cancellationToken); + await RunAllHealthAsync(cancellationToken); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await RefreshSelectedHostAsync(cancellationToken); + await RunAllHealthAsync(cancellationToken); + } + } + catch (OperationCanceledException) + { + } + } +} diff --git a/src/Vantage.App/ViewModels/OverviewViewModel.cs b/src/Vantage.App/ViewModels/OverviewViewModel.cs new file mode 100644 index 0000000..7b4d76f --- /dev/null +++ b/src/Vantage.App/ViewModels/OverviewViewModel.cs @@ -0,0 +1,42 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Vantage.App.ViewModels; + +public partial class OverviewViewModel : ViewModelBase +{ + private readonly ObservableCollection hosts; + + [ObservableProperty] private int totalHosts; + [ObservableProperty] private int onlineHosts; + [ObservableProperty] private int offlineHosts; + [ObservableProperty] private double averageCpuPercent; + [ObservableProperty] private double peakCpuPercent; + [ObservableProperty] private string peakCpuHost = "-"; + [ObservableProperty] private double peakMemoryPercent; + [ObservableProperty] private string peakMemoryHost = "-"; + [ObservableProperty] private int firingAlerts; + + public OverviewViewModel(ObservableCollection hosts) + { + this.hosts = hosts; + } + + public void Refresh() + { + TotalHosts = hosts.Count; + OnlineHosts = hosts.Count(host => host.IsOnline); + OfflineHosts = TotalHosts - OnlineHosts; + + var online = hosts.Where(host => host.IsOnline).ToArray(); + AverageCpuPercent = online.Length == 0 ? 0 : Math.Round(online.Average(host => host.CpuUsagePercent), 1); + + var hottestCpu = online.OrderByDescending(host => host.CpuUsagePercent).FirstOrDefault(); + PeakCpuPercent = hottestCpu?.CpuUsagePercent ?? 0; + PeakCpuHost = hottestCpu?.Name ?? "-"; + + var hottestMemory = online.OrderByDescending(host => host.MemoryUsedPercent).FirstOrDefault(); + PeakMemoryPercent = hottestMemory?.MemoryUsedPercent ?? 0; + PeakMemoryHost = hottestMemory?.Name ?? "-"; + } +}