From f24cec73e4b2d9a187c38ce749a4aaf16ff1c83b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:05:15 -0500 Subject: [PATCH] Add check for updates feature (#1) Both Dashboard and Lite now check the GitHub releases API for new versions. On startup (once per 24h, configurable), a notification appears if an update is available. The About dialog's "Check for Updates" link now performs a live check and shows the result inline with a clickable download link. Also simplified the Dashboard About dialog credits section to match Lite. Co-Authored-By: Claude Opus 4.6 --- Dashboard/AboutWindow.xaml | 7 +- Dashboard/AboutWindow.xaml.cs | 35 +++++++- Dashboard/MainWindow.xaml.cs | 24 ++++++ Dashboard/Models/UserPreferences.cs | 3 + Dashboard/Services/UpdateCheckService.cs | 101 ++++++++++++++++++++++ Lite/App.xaml.cs | 6 ++ Lite/MainWindow.xaml.cs | 33 ++++++++ Lite/Services/UpdateCheckService.cs | 102 +++++++++++++++++++++++ Lite/Windows/AboutWindow.xaml | 1 + Lite/Windows/AboutWindow.xaml.cs | 35 +++++++- 10 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 Dashboard/Services/UpdateCheckService.cs create mode 100644 Lite/Services/UpdateCheckService.cs diff --git a/Dashboard/AboutWindow.xaml b/Dashboard/AboutWindow.xaml index 432be912..b8760fa4 100644 --- a/Dashboard/AboutWindow.xaml +++ b/Dashboard/AboutWindow.xaml @@ -43,13 +43,12 @@ Check for Updates + - + - - - + www.erikdarling.com diff --git a/Dashboard/AboutWindow.xaml.cs b/Dashboard/AboutWindow.xaml.cs index e661dfd5..0e61086e 100644 --- a/Dashboard/AboutWindow.xaml.cs +++ b/Dashboard/AboutWindow.xaml.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Reflection; using System.Windows; +using PerformanceMonitorDashboard.Services; namespace PerformanceMonitorDashboard { @@ -17,6 +18,8 @@ public partial class AboutWindow : Window private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases"; private const string DarlingDataUrl = "https://www.erikdarling.com"; + private string? _updateReleaseUrl; + public AboutWindow() { InitializeComponent(); @@ -39,9 +42,37 @@ private void ReportIssueLink_Click(object sender, RoutedEventArgs e) OpenUrl(IssuesUrl); } - private void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) + private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) + { + UpdateStatusText.Text = "Checking for updates..."; + UpdateStatusText.Visibility = Visibility.Visible; + + var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true); + + if (result == null) + { + UpdateStatusText.Text = "Unable to check for updates. Please try again later."; + } + else if (result.IsUpdateAvailable) + { + _updateReleaseUrl = result.ReleaseUrl; + UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})"; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + } + else + { + UpdateStatusText.Text = $"You're up to date ({result.CurrentVersion})"; + } + } + + private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) { - OpenUrl(ReleasesUrl); + if (!string.IsNullOrEmpty(_updateReleaseUrl)) + OpenUrl(_updateReleaseUrl); } private void DarlingDataLink_Click(object sender, RoutedEventArgs e) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 0a031764..f665f82c 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -137,6 +137,30 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) _displayRefreshTimer.Start(); await CheckAllConnectionsAsync(); + + _ = CheckForUpdatesOnStartupAsync(); + } + + private async Task CheckForUpdatesOnStartupAsync() + { + try + { + var prefs = _preferencesService.GetPreferences(); + if (!prefs.CheckForUpdatesOnStartup) return; + + var result = await UpdateCheckService.CheckForUpdateAsync(); + if (result?.IsUpdateAvailable == true) + { + _notificationService?.ShowNotification( + "Update Available", + $"Performance Monitor {result.LatestVersion} is available (you have {result.CurrentVersion}). Check About for details.", + NotificationType.Info); + } + } + catch + { + // Never crash on update check failure + } } private void StartMcpServerIfEnabled() diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index a915900e..58f8723d 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -92,6 +92,9 @@ public class UserPreferences public bool McpEnabled { get; set; } = false; public int McpPort { get; set; } = 5150; + // Update check settings + public bool CheckForUpdatesOnStartup { get; set; } = true; + // Alert suppression (persisted) public List SilencedServers { get; set; } = new(); public List SilencedServerTabs { get; set; } = new(); diff --git a/Dashboard/Services/UpdateCheckService.cs b/Dashboard/Services/UpdateCheckService.cs new file mode 100644 index 00000000..13c5edc2 --- /dev/null +++ b/Dashboard/Services/UpdateCheckService.cs @@ -0,0 +1,101 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +using System; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; + +namespace PerformanceMonitorDashboard.Services +{ + public record UpdateInfo( + bool IsUpdateAvailable, + string CurrentVersion, + string LatestVersion, + string ReleaseUrl, + string ReleaseNotes); + + public static class UpdateCheckService + { + private const string ReleasesApiUrl = + "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest"; + + private static readonly HttpClient HttpClient = CreateHttpClient(); + private static UpdateInfo? _cachedResult; + private static DateTime _cacheExpiry = DateTime.MinValue; + + private static HttpClient CreateHttpClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + return client; + } + + public static async Task CheckForUpdateAsync(bool bypassCache = false) + { + try + { + if (!bypassCache && _cachedResult != null && DateTime.UtcNow < _cacheExpiry) + return _cachedResult; + + var response = await HttpClient.GetAsync(ReleasesApiUrl).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var tagName = root.GetProperty("tag_name").GetString() ?? ""; + var releaseUrl = root.GetProperty("html_url").GetString() ?? ""; + var releaseNotes = root.TryGetProperty("body", out var bodyProp) + ? bodyProp.GetString() ?? "" + : ""; + + var currentVersion = GetCurrentVersion(); + var latestVersion = ParseVersion(tagName); + var isUpdateAvailable = latestVersion != null + && currentVersion != null + && latestVersion > currentVersion; + + var result = new UpdateInfo( + isUpdateAvailable, + FormatVersion(currentVersion), + tagName, + releaseUrl, + releaseNotes); + + _cachedResult = result; + _cacheExpiry = DateTime.UtcNow.AddHours(24); + + return result; + } + catch + { + return null; + } + } + + private static Version? GetCurrentVersion() + { + return Assembly.GetExecutingAssembly().GetName().Version; + } + + private static Version? ParseVersion(string tagName) + { + var versionString = tagName.TrimStart('v', 'V'); + return Version.TryParse(versionString, out var version) ? version : null; + } + + private static string FormatVersion(Version? version) + { + if (version == null) return "unknown"; + return $"{version.Major}.{version.Minor}.{version.Build}"; + } + } +} diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index 21dc418c..cd9dba13 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -56,6 +56,9 @@ public partial class App : Application public static bool AlertDeadlockEnabled { get; set; } = true; public static int AlertDeadlockThreshold { get; set; } = 1; + /* Update check settings */ + public static bool CheckForUpdatesOnStartup { get; set; } = true; + /* SMTP email alert settings */ public static bool SmtpEnabled { get; set; } = false; public static string SmtpServer { get; set; } = ""; @@ -195,6 +198,9 @@ public static void LoadAlertSettings() if (root.TryGetProperty("alert_deadlock_enabled", out v)) AlertDeadlockEnabled = v.GetBoolean(); if (root.TryGetProperty("alert_deadlock_threshold", out v)) AlertDeadlockThreshold = v.GetInt32(); + /* Update check settings */ + if (root.TryGetProperty("check_for_updates_on_startup", out v)) CheckForUpdatesOnStartup = v.GetBoolean(); + /* SMTP settings */ if (root.TryGetProperty("smtp_enabled", out v)) SmtpEnabled = v.GetBoolean(); if (root.TryGetProperty("smtp_server", out v)) SmtpServer = v.GetString() ?? ""; diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index cfae29a2..fb6589ef 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -121,6 +121,8 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) await RefreshOverviewAsync(); StatusText.Text = "Ready - Collection active"; + + _ = CheckForUpdatesOnStartupAsync(); } catch (Exception ex) { @@ -133,6 +135,37 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) } } + private async Task CheckForUpdatesOnStartupAsync() + { + try + { + if (!App.CheckForUpdatesOnStartup) return; + + var result = await UpdateCheckService.CheckForUpdateAsync(); + if (result?.IsUpdateAvailable == true) + { + var answer = MessageBox.Show( + $"Performance Monitor {result.LatestVersion} is available (you have {result.CurrentVersion}).\n\nWould you like to open the download page?", + "Update Available", + MessageBoxButton.YesNo, + MessageBoxImage.Information); + + if (answer == MessageBoxResult.Yes) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = result.ReleaseUrl, + UseShellExecute = true + }); + } + } + } + catch + { + // Never crash on update check failure + } + } + private async void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e) { // Dispose system tray diff --git a/Lite/Services/UpdateCheckService.cs b/Lite/Services/UpdateCheckService.cs new file mode 100644 index 00000000..fb598b4e --- /dev/null +++ b/Lite/Services/UpdateCheckService.cs @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; + +namespace PerformanceMonitorLite.Services; + +public record UpdateInfo( + bool IsUpdateAvailable, + string CurrentVersion, + string LatestVersion, + string ReleaseUrl, + string ReleaseNotes); + +public static class UpdateCheckService +{ + private const string ReleasesApiUrl = + "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest"; + + private static readonly HttpClient HttpClient = CreateHttpClient(); + private static UpdateInfo? _cachedResult; + private static DateTime _cacheExpiry = DateTime.MinValue; + + private static HttpClient CreateHttpClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + return client; + } + + public static async Task CheckForUpdateAsync(bool bypassCache = false) + { + try + { + if (!bypassCache && _cachedResult != null && DateTime.UtcNow < _cacheExpiry) + return _cachedResult; + + var response = await HttpClient.GetAsync(ReleasesApiUrl).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var tagName = root.GetProperty("tag_name").GetString() ?? ""; + var releaseUrl = root.GetProperty("html_url").GetString() ?? ""; + var releaseNotes = root.TryGetProperty("body", out var bodyProp) + ? bodyProp.GetString() ?? "" + : ""; + + var currentVersion = GetCurrentVersion(); + var latestVersion = ParseVersion(tagName); + var isUpdateAvailable = latestVersion != null + && currentVersion != null + && latestVersion > currentVersion; + + var result = new UpdateInfo( + isUpdateAvailable, + FormatVersion(currentVersion), + tagName, + releaseUrl, + releaseNotes); + + _cachedResult = result; + _cacheExpiry = DateTime.UtcNow.AddHours(24); + + return result; + } + catch + { + return null; + } + } + + private static Version? GetCurrentVersion() + { + return Assembly.GetExecutingAssembly().GetName().Version; + } + + private static Version? ParseVersion(string tagName) + { + var versionString = tagName.TrimStart('v', 'V'); + return Version.TryParse(versionString, out var version) ? version : null; + } + + private static string FormatVersion(Version? version) + { + if (version == null) return "unknown"; + return $"{version.Major}.{version.Minor}.{version.Build}"; + } +} diff --git a/Lite/Windows/AboutWindow.xaml b/Lite/Windows/AboutWindow.xaml index 404739d1..4b55197f 100644 --- a/Lite/Windows/AboutWindow.xaml +++ b/Lite/Windows/AboutWindow.xaml @@ -51,6 +51,7 @@ Check for Updates + www.erikdarling.com diff --git a/Lite/Windows/AboutWindow.xaml.cs b/Lite/Windows/AboutWindow.xaml.cs index 496b5a79..05a388a4 100644 --- a/Lite/Windows/AboutWindow.xaml.cs +++ b/Lite/Windows/AboutWindow.xaml.cs @@ -9,6 +9,7 @@ using System.Diagnostics; using System.Reflection; using System.Windows; +using PerformanceMonitorLite.Services; namespace PerformanceMonitorLite.Windows; @@ -19,6 +20,8 @@ public partial class AboutWindow : Window private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases"; private const string DarlingDataUrl = "https://www.erikdarling.com"; + private string? _updateReleaseUrl; + public AboutWindow() { InitializeComponent(); @@ -37,9 +40,37 @@ private void ReportIssueLink_Click(object sender, RoutedEventArgs e) OpenUrl(IssuesUrl); } - private void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) + private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) + { + UpdateStatusText.Text = "Checking for updates..."; + UpdateStatusText.Visibility = Visibility.Visible; + + var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true); + + if (result == null) + { + UpdateStatusText.Text = "Unable to check for updates. Please try again later."; + } + else if (result.IsUpdateAvailable) + { + _updateReleaseUrl = result.ReleaseUrl; + UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})"; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + } + else + { + UpdateStatusText.Text = $"You're up to date ({result.CurrentVersion})"; + } + } + + private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) { - OpenUrl(ReleasesUrl); + if (!string.IsNullOrEmpty(_updateReleaseUrl)) + OpenUrl(_updateReleaseUrl); } private void DarlingDataLink_Click(object sender, RoutedEventArgs e)