diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml index 4db4f1b8..01384324 100644 --- a/Lite/MainWindow.xaml +++ b/Lite/MainWindow.xaml @@ -108,12 +108,15 @@ diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 0c8427d6..b247b241 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -35,6 +35,7 @@ public partial class MainWindow : Window private SystemTrayService? _trayService; private readonly Dictionary _openServerTabs = new(); private readonly Dictionary _previousConnectionStates = new(); + private readonly Dictionary _previousCollectorErrorStates = new(); private readonly Dictionary _lastCpuAlert = new(); private readonly Dictionary _lastBlockingAlert = new(); private readonly Dictionary _lastDeadlockAlert = new(); @@ -255,6 +256,9 @@ private void RefreshServerList() foreach (var server in servers) { server.IsOnline = _serverManager.GetConnectionStatus(server.Id).IsOnline; + server.HasCollectorErrors = _collectorService != null + && server.IsOnline == true + && _collectorService.GetHealthSummary(server).ErroringCollectors > 0; } ServerListView.ItemsSource = servers; @@ -380,6 +384,8 @@ private async Task RefreshOverviewAsync() summary.ServerName = server.ServerName; var connStatus = _serverManager.GetConnectionStatus(server.Id); summary.IsOnline = connStatus.IsOnline; + if (_collectorService != null && connStatus.IsOnline == true) + summary.HasCollectorErrors = _collectorService.GetHealthSummary(server).ErroringCollectors > 0; summaries.Add(summary); } } @@ -898,6 +904,9 @@ private void CheckConnectionsAndNotify() if (status?.IsOnline == null) continue; bool isOnline = status.IsOnline == true; + bool hasErrors = _collectorService != null && isOnline + && _collectorService.GetHealthSummary(server).ErroringCollectors > 0; + server.HasCollectorErrors = hasErrors; if (_previousConnectionStates.TryGetValue(server.Id, out var wasOnline)) { @@ -930,7 +939,11 @@ private void CheckConnectionsAndNotify() needsRefresh = true; } + if (_previousCollectorErrorStates.TryGetValue(server.Id, out var prevHasErrors) && prevHasErrors != hasErrors) + needsRefresh = true; + _previousConnectionStates[server.Id] = isOnline; + _previousCollectorErrorStates[server.Id] = hasErrors; } if (needsRefresh) diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs index 610b2b23..b9211dec 100644 --- a/Lite/Models/ServerConnection.cs +++ b/Lite/Models/ServerConnection.cs @@ -88,6 +88,30 @@ public bool UseWindowsAuth [JsonIgnore] public bool? IsOnline { get; set; } + /// + /// Whether one or more collectors are currently failing for this server. + /// null = not yet determined; true = some collectors have consecutive errors; false = all healthy. + /// + [JsonIgnore] + public bool? HasCollectorErrors { get; set; } + + /// + /// Computed dot status for the sidebar indicator. One of: "Unknown", "Online", "Warning", "Offline". + /// Drives the Ellipse fill via DataTrigger in MainWindow.xaml. + /// + [JsonIgnore] + public string DotStatus + { + get + { + if (IsOnline == true) + return HasCollectorErrors == true ? "Warning" : "Online"; + if (IsOnline == false) + return "Offline"; + return "Unknown"; // null — not yet checked + } + } + /// /// Display-only property for showing status in UI. /// diff --git a/Lite/Services/LocalDataService.Overview.cs b/Lite/Services/LocalDataService.Overview.cs index b86f6c4a..eee8a893 100644 --- a/Lite/Services/LocalDataService.Overview.cs +++ b/Lite/Services/LocalDataService.Overview.cs @@ -126,6 +126,8 @@ public class ServerSummaryItem public string ServerName { get; set; } = ""; public int ServerId { get; set; } public bool? IsOnline { get; set; } + /// True when the server is reachable but one or more collectors have consecutive errors. + public bool HasCollectorErrors { get; set; } public double? CpuPercent { get; set; } public double? MemoryMb { get; set; } public int BlockingCount { get; set; } @@ -139,8 +141,20 @@ public class ServerSummaryItem public string LastCollectionDisplay => LastCollectionTime.HasValue ? ServerTimeHelper.FormatServerTime(LastCollectionTime, "HH:mm:ss") : "Never"; /* Connection status */ - public string StatusDisplay => IsOnline switch { true => "Online", false => "Offline", _ => "Unknown" }; - public SolidColorBrush StatusBrush => MakeBrush(IsOnline switch { true => "#81C784", false => "#E57373", _ => "#888888" }); + public string StatusDisplay => IsOnline switch + { + true when HasCollectorErrors => "Warning", + true => "Online", + false => "Offline", + _ => "Unknown" + }; + public SolidColorBrush StatusBrush => MakeBrush(IsOnline switch + { + true when HasCollectorErrors => "#FFD54F", // amber — connected but collectors failing + true => "#81C784", + false => "#E57373", + _ => "#888888" + }); public bool IsOffline => IsOnline == false; /* Color coding */ @@ -152,6 +166,7 @@ public class ServerSummaryItem DeadlockCount > 0 ? "#E57373" : BlockingCount > 0 ? "#FFB74D" : CpuPercent >= 80 ? "#FFB74D" : + HasCollectorErrors ? "#FFD54F" : // amber border when collectors are failing "#2a2d35"); private static SolidColorBrush MakeBrush(string hex) diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index 10eb30c2..fc9fe77f 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -126,6 +126,12 @@ public RemoteCollectorService( /// public Task CheckpointAsync() => _duckDb.CheckpointAsync(); + /// + /// Gets a summary of collector health for a specific server connection. + /// + public CollectorHealthSummary GetHealthSummary(ServerConnection server) + => GetHealthSummary(GetServerId(server)); + /// /// Gets a summary of collector health. When serverId is provided, filters to that server only. /// diff --git a/Lite/Services/ServerManager.cs b/Lite/Services/ServerManager.cs index a007c2bb..bc738a29 100644 --- a/Lite/Services/ServerManager.cs +++ b/Lite/Services/ServerManager.cs @@ -330,50 +330,52 @@ public async Task CheckConnectionAsync(string serverId, using var connection = new SqlConnection(builder.ConnectionString); await connection.OpenAsync(); - // Query server start time, version, and UTC offset to verify connectivity - using var command = new SqlCommand(@" - SELECT - sqlserver_start_time, - @@VERSION AS sql_version, - CONVERT(integer, SERVERPROPERTY('ProductMajorVersion')) AS major_version, - DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, - CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, - CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds - FROM sys.dm_os_sys_info", connection); - command.CommandTimeout = ConnectionCheckTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) + // Connection succeeded — server is reachable regardless of DMV permissions below. + status.IsOnline = true; + status.ErrorMessage = null; + status.UserCancelledMfa = false; // Clear cancellation flag on successful connection + + // Query server metadata (version, start time, UTC offset). + // Wrapped in its own try/catch: a permissions failure on sys.dm_os_sys_info + // must NOT flip IsOnline back to false — the login itself worked. + try { - status.IsOnline = true; - status.ErrorMessage = null; - status.UserCancelledMfa = false; // Clear cancellation flag on successful connection - - if (!reader.IsDBNull(0)) - { - status.ServerStartTime = reader.GetDateTime(0); - } - if (!reader.IsDBNull(1)) - { - status.SqlServerVersion = reader.GetString(1); - } - if (!reader.IsDBNull(2)) - { - status.SqlMajorVersion = Convert.ToInt32(reader.GetValue(2)); - } - if (!reader.IsDBNull(3)) - { - status.UtcOffsetMinutes = Convert.ToInt32(reader.GetValue(3)); - } - if (!reader.IsDBNull(4)) + using var command = new SqlCommand(@" + SELECT + sqlserver_start_time, + @@VERSION AS sql_version, + CONVERT(integer, SERVERPROPERTY('ProductMajorVersion')) AS major_version, + DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, + CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, + CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds + FROM sys.dm_os_sys_info", connection); + command.CommandTimeout = ConnectionCheckTimeoutSeconds; + + using var reader = await command.ExecuteReaderAsync(); + if (await reader.ReadAsync()) { - status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(4)); - } - if (!reader.IsDBNull(5)) - { - status.IsAwsRds = Convert.ToInt32(reader.GetValue(5)) == 1; + if (!reader.IsDBNull(0)) + status.ServerStartTime = reader.GetDateTime(0); + if (!reader.IsDBNull(1)) + status.SqlServerVersion = reader.GetString(1); + if (!reader.IsDBNull(2)) + status.SqlMajorVersion = Convert.ToInt32(reader.GetValue(2)); + if (!reader.IsDBNull(3)) + status.UtcOffsetMinutes = Convert.ToInt32(reader.GetValue(3)); + if (!reader.IsDBNull(4)) + status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(4)); + if (!reader.IsDBNull(5)) + status.IsAwsRds = Convert.ToInt32(reader.GetValue(5)) == 1; } } + catch (SqlException metaEx) + { + // Metadata query failed (e.g. no VIEW SERVER STATE permission) but the + // server IS reachable — keep IsOnline = true, just record the warning. + status.ErrorMessage = $"Connected, but metadata query failed: {metaEx.Message}"; + _logger?.LogWarning("Metadata query failed for server '{DisplayName}' (server is still online): {Message}", + server.DisplayName, metaEx.Message); + } _logger?.LogDebug("Connectivity check passed for server '{DisplayName}'", server.DisplayName); }